From 1beb088f904b9de3546ba3daf6a44cb157a570cf Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 18 Nov 2023 17:49:28 +0100 Subject: [PATCH 01/13] Startup listen message --- main.go | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index db6e5e5..f61202b 100644 --- a/main.go +++ b/main.go @@ -11,15 +11,16 @@ import ( "image/draw" "image/png" "io/fs" - "io/ioutil" "math" "math/rand" "mime" + "net" "path" "path/filepath" "regexp" "runtime" "sort" + "strconv" "strings" "sync" "testing" @@ -1113,7 +1114,7 @@ func loadConfiguration(path string) AppConfig { var appConfig AppConfig log.Printf("config path %v", path) - bytes, err := ioutil.ReadFile(path) + bytes, err := os.ReadFile(path) if err != nil { log.Printf("unable to open %s, using defaults (%s)\n", path, err.Error()) appConfig = defaults @@ -1240,6 +1241,10 @@ func main() { startupTime = time.Now() + log.SetFlags( + 0, + ) + testing.Init() versionFlag := flag.Bool("version", false, "print version and exit") vacuumFlag := flag.Bool("vacuum", false, "clean database for smaller size and better performance, and exit") @@ -1443,11 +1448,11 @@ func main() { r.Mount("/", openapi.Handler(&api)) r.Mount("/metrics", promhttp.Handler()) }) - msg := fmt.Sprintf("api at %v%v", addr, apiPrefix) r.Mount("/debug", middleware.Profiler()) r.Handle("/debug/fgprof", fgprof.Handler()) + msg := "" if apiPrefix != "/" { // Hardcode well-known mime types, see https://github.com/golang/go/issues/32350 mime.AddExtensionType(".js", "text/javascript") @@ -1488,11 +1493,98 @@ func main() { } r.Handle("/*", uihandler) }) - msg = fmt.Sprintf("ui at %v, %s", addr, msg) + msg = fmt.Sprintf("app running (api under %s)", apiPrefix) + } else { + msg = "api running" } // addExampleScene() + log.Printf("") log.Println(msg) - log.Fatal(http.ListenAndServe(addr, r)) + log.Fatal(listenAndServe(addr, r)) +} + +type listenUrl struct { + local bool + ipv6 bool + url string +} + +func getListenUrls(addr net.Addr) ([]listenUrl, error) { + var urls []listenUrl + switch vaddr := addr.(type) { + case *net.TCPAddr: + if vaddr.IP.IsUnspecified() { + ifaces, err := net.Interfaces() + if err != nil { + return urls, fmt.Errorf("unable to list interfaces: %v", err) + } + for _, i := range ifaces { + addrs, err := i.Addrs() + if err != nil { + return urls, fmt.Errorf("unable to list addresses for %v: %v", i.Name, err) + } + for _, a := range addrs { + switch v := a.(type) { + case *net.IPNet: + urls = append(urls, listenUrl{ + local: v.IP.IsLoopback(), + ipv6: v.IP.To4() == nil, + url: fmt.Sprintf("http://%v", net.JoinHostPort(v.IP.String(), strconv.Itoa(vaddr.Port))), + }) + default: + urls = append(urls, listenUrl{ + url: fmt.Sprintf("http://%v", v), + }) + } + } + } + } else { + urls = append(urls, listenUrl{ + local: vaddr.IP.IsLoopback(), + url: fmt.Sprintf("http://%v", vaddr.AddrPort()), + }) + } + default: + urls = append(urls, listenUrl{ + url: fmt.Sprintf("http://%v", addr), + }) + } + return urls, nil +} + +func listenAndServe(addr string, handler http.Handler) error { + srv := &http.Server{Addr: addr, Handler: handler} + if addr == "" { + addr = ":http" + } + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + a := ln.Addr() + urls, err := getListenUrls(a) + if err != nil { + return err + } + // Sort by ipv4 first, then local, then url + sort.Slice(urls, func(i, j int) bool { + if urls[i].ipv6 != urls[j].ipv6 { + return !urls[i].ipv6 + } + if urls[i].local != urls[j].local { + return urls[i].local + } + return urls[i].url < urls[j].url + }) + + for _, url := range urls { + prefix := "network" + if url.local { + prefix = "local" + } + fmt.Printf(" %-8s %s\n", prefix, url.url) + } + return srv.Serve(ln) } From 6a7bda681d9301e354b8198fdef4ae9a53bfb6b6 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 18 Nov 2023 18:27:44 +0100 Subject: [PATCH 02/13] Initial Playwright tests --- .../tests/connection-error.feature.spec.js | 38 + .features-gen/tests/first-run.feature.spec.js | 22 + .github/workflows/playwright.yml | 27 + .gitignore | 6 + .vscode/launch.json | 12 +- justfile | 6 + main.go | 2 +- package-lock.json | 3842 +++++++++++++++++ package.json | 29 + playwright.config.ts | 91 + tests/connection-error.feature | 25 + tests/first-run.feature | 10 + tests/fixtures.ts | 102 + tests/steps.ts | 73 + tests/teardown.ts | 15 + 15 files changed, 4298 insertions(+), 2 deletions(-) create mode 100644 .features-gen/tests/connection-error.feature.spec.js create mode 100644 .features-gen/tests/first-run.feature.spec.js create mode 100644 .github/workflows/playwright.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 tests/connection-error.feature create mode 100644 tests/first-run.feature create mode 100644 tests/fixtures.ts create mode 100644 tests/steps.ts create mode 100644 tests/teardown.ts diff --git a/.features-gen/tests/connection-error.feature.spec.js b/.features-gen/tests/connection-error.feature.spec.js new file mode 100644 index 0000000..b4e5a4d --- /dev/null +++ b/.features-gen/tests/connection-error.feature.spec.js @@ -0,0 +1,38 @@ +/** Generated from: tests\connection-error.feature */ +import { test } from "..\\..\\tests\\fixtures.ts"; + +test.describe("Connection Error Message", () => { + + test("UI loads, but API is down", async ({ Given, app, When, Then, page }) => { + await Given("an empty working directory", null, { app }); + await When("the user opens the home page", null, { app }); + await Then("the page shows a progress bar", null, { page }); + await Then("the page shows \"Connection error\"", null, { page }); + }); + + test("UI loads, API is up intermittently", async ({ Given, app, And, When, Then, page }) => { + await Given("an empty working directory", null, { app }); + await And("a running API", null, { app }); + await When("the user opens the home page", null, { app }); + await Then("the page shows \"Photos\"", null, { page }); + await Then("the page does not show \"Connection error\"", null, { page }); + await And("the page shows \"No collections\"", null, { page }); + await When("the API goes down", null, { app }); + await And("the user waits for 2 seconds", null, { page }); + await And("the user switches away and back to the page", null, { page }); + await And("the user waits for 5 seconds", null, { page }); + await Then("the page shows \"Connection error\"", null, { page }); + await When("the API comes back up", null, { app }); + await Then("the page shows \"Photos\"", null, { page }); + await When("the user clicks \"Retry\"", null, { page }); + await Then("the page does not show \"Connecting...\"", null, { page }); + await And("the page does not show \"Connection error\"", null, { page }); + }); + +}); + +// == technical section == + +test.use({ + $test: ({}, use) => use(test), +}); \ No newline at end of file diff --git a/.features-gen/tests/first-run.feature.spec.js b/.features-gen/tests/first-run.feature.spec.js new file mode 100644 index 0000000..03d6b1e --- /dev/null +++ b/.features-gen/tests/first-run.feature.spec.js @@ -0,0 +1,22 @@ +/** Generated from: tests\first-run.feature */ +import { test } from "..\\..\\tests\\fixtures.ts"; + +test.describe("First User Experience", () => { + + test("Empty Folder", async ({ Given, app, When, Then, page, And }) => { + await Given("an empty working directory", null, { app }); + await When("the user runs the app", null, { app }); + await Then("the app logs \"app running\"", null, { app }); + await When("the user opens the home page", null, { app }); + await Then("the page shows \"Photos\"", null, { page }); + await Then("the page does not show \"Connection error\"", null, { page }); + await And("the page shows \"No collections\"", null, { page }); + }); + +}); + +// == technical section == + +test.use({ + $test: ({}, use) => use(test), +}); \ No newline at end of file diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..5156520 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index df1b924..d0d5264 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,9 @@ tmp tools/jupyter/data .env dist/ +node_modules/ +/test-results/ +/test-tmp/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/.vscode/launch.json b/.vscode/launch.json index ae4cbc1..cb80d8d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,6 +18,16 @@ "mode": "auto", "program": "${workspaceFolder}", "buildFlags": "-tags embedui" - } + }, + { + "name": "Debug bddgen", + "request": "launch", + "program": "${workspaceFolder}/node_modules/playwright-bdd/dist/cli/index.js", + "autoAttachChildProcesses": true, + "skipFiles": [ + "/**" + ], + "type": "node" + }, ] } \ No newline at end of file diff --git a/justfile b/justfile index 2bed33b..6298338 100644 --- a/justfile +++ b/justfile @@ -15,6 +15,9 @@ build-docs: build-local: goreleaser build --snapshot --single-target --clean +e2e *args: + npm run watch + # Download geopackage to be embedded via -tags embedgeo assets: mkdir -p data/geo @@ -52,6 +55,9 @@ ui: watch: watchexec --exts go,yaml -r just run +watch-build: + watchexec --exts go,yaml -r 'just build && echo build successful' + db-add migration_file_name: migrate create -ext sql -dir db/migrations -seq {{migration_file_name}} diff --git a/main.go b/main.go index f61202b..d2c40fc 100644 --- a/main.go +++ b/main.go @@ -1584,7 +1584,7 @@ func listenAndServe(addr string, handler http.Handler) error { if url.local { prefix = "local" } - fmt.Printf(" %-8s %s\n", prefix, url.url) + log.Printf(" %-8s %s\n", prefix, url.url) } return srv.Serve(ln) } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..153b04c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3842 @@ +{ + "name": "photofield", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "photofield", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.39.0", + "@types/node": "^20.9.0", + "nodemon": "^3.0.1", + "npm-run-all": "^4.1.5", + "playwright-bdd": "^5.4.0", + "typescript": "^5.2.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "peer": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "peer": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "peer": true + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "peer": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "peer": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "peer": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "peer": true + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "peer": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cucumber/ci-environment": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/ci-environment/-/ci-environment-9.2.0.tgz", + "integrity": "sha512-jLzRtVwdtNt+uAmTwvXwW9iGYLEOJFpDSmnx/dgoMGKXUWRx1UHT86Q696CLdgXO8kyTwsgJY0c6n5SW9VitAA==", + "dev": true, + "peer": true + }, + "node_modules/@cucumber/cucumber": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber/-/cucumber-10.0.1.tgz", + "integrity": "sha512-g7W7SQnNMSNnMRQVGubjefCxdgNFyq4P3qxT2Ve7Xhh8ZLoNkoRDcWsyfKQVWnxNfgW3aGJmxbucWRoTi+ZUqg==", + "dev": true, + "peer": true, + "dependencies": { + "@cucumber/ci-environment": "9.2.0", + "@cucumber/cucumber-expressions": "16.1.2", + "@cucumber/gherkin": "26.2.0", + "@cucumber/gherkin-streams": "5.0.1", + "@cucumber/gherkin-utils": "8.0.2", + "@cucumber/html-formatter": "20.4.0", + "@cucumber/message-streams": "4.0.1", + "@cucumber/messages": "22.0.0", + "@cucumber/tag-expressions": "5.0.1", + "assertion-error-formatter": "^3.0.0", + "capital-case": "^1.0.4", + "chalk": "^4.1.2", + "cli-table3": "0.6.3", + "commander": "^10.0.0", + "debug": "^4.3.4", + "error-stack-parser": "^2.1.4", + "figures": "^3.2.0", + "glob": "^10.3.10", + "has-ansi": "^4.0.1", + "indent-string": "^4.0.0", + "is-installed-globally": "^0.4.0", + "is-stream": "^2.0.0", + "knuth-shuffle-seeded": "^1.0.6", + "lodash.merge": "^4.6.2", + "lodash.mergewith": "^4.6.2", + "luxon": "3.2.1", + "mkdirp": "^2.1.5", + "mz": "^2.7.0", + "progress": "^2.0.3", + "read-pkg-up": "^7.0.1", + "resolve-pkg": "^2.0.0", + "semver": "7.5.3", + "string-argv": "^0.3.1", + "strip-ansi": "6.0.1", + "supports-color": "^8.1.1", + "tmp": "^0.2.1", + "util-arity": "^1.1.0", + "verror": "^1.10.0", + "xmlbuilder": "^15.1.1", + "yaml": "^2.2.2", + "yup": "1.2.0" + }, + "bin": { + "cucumber-js": "bin/cucumber.js" + }, + "engines": { + "node": "18 || >=20" + } + }, + "node_modules/@cucumber/cucumber-expressions": { + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-16.1.2.tgz", + "integrity": "sha512-CfHEbxJ5FqBwF6mJyLLz4B353gyHkoi6cCL4J0lfDZ+GorpcWw4n2OUAdxJmP7ZlREANWoTFlp4FhmkLKrCfUA==", + "dev": true, + "peer": true, + "dependencies": { + "regexp-match-indices": "1.0.2" + } + }, + "node_modules/@cucumber/gherkin": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-26.2.0.tgz", + "integrity": "sha512-iRSiK8YAIHAmLrn/mUfpAx7OXZ7LyNlh1zT89RoziSVCbqSVDxJS6ckEzW8loxs+EEXl0dKPQOXiDmbHV+C/fA==", + "dev": true, + "peer": true, + "dependencies": { + "@cucumber/messages": ">=19.1.4 <=22" + } + }, + "node_modules/@cucumber/gherkin-streams": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-streams/-/gherkin-streams-5.0.1.tgz", + "integrity": "sha512-/7VkIE/ASxIP/jd4Crlp4JHXqdNFxPGQokqWqsaCCiqBiu5qHoKMxcWNlp9njVL/n9yN4S08OmY3ZR8uC5x74Q==", + "dev": true, + "peer": true, + "dependencies": { + "commander": "9.1.0", + "source-map-support": "0.5.21" + }, + "bin": { + "gherkin-javascript": "bin/gherkin" + }, + "peerDependencies": { + "@cucumber/gherkin": ">=22.0.0", + "@cucumber/message-streams": ">=4.0.0", + "@cucumber/messages": ">=17.1.1" + } + }, + "node_modules/@cucumber/gherkin-streams/node_modules/commander": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.1.0.tgz", + "integrity": "sha512-i0/MaqBtdbnJ4XQs4Pmyb+oFQl+q0lsAmokVUH92SlSw4fkeAcG3bVon+Qt7hmtF+u3Het6o4VgrcY3qAoEB6w==", + "dev": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/@cucumber/gherkin-utils": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-8.0.2.tgz", + "integrity": "sha512-aQlziN3r3cTwprEDbLEcFoMRQajb9DTOu2OZZp5xkuNz6bjSTowSY90lHUD2pWT7jhEEckZRIREnk7MAwC2d1A==", + "dev": true, + "peer": true, + "dependencies": { + "@cucumber/gherkin": "^25.0.0", + "@cucumber/messages": "^19.1.4", + "@teppeis/multimaps": "2.0.0", + "commander": "9.4.1", + "source-map-support": "^0.5.21" + }, + "bin": { + "gherkin-utils": "bin/gherkin-utils" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin": { + "version": "25.0.2", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-25.0.2.tgz", + "integrity": "sha512-EdsrR33Y5GjuOoe2Kq5Y9DYwgNRtUD32H4y2hCrT6+AWo7ibUQu7H+oiWTgfVhwbkHsZmksxHSxXz/AwqqyCRQ==", + "dev": true, + "peer": true, + "dependencies": { + "@cucumber/messages": "^19.1.4" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/messages": { + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-19.1.4.tgz", + "integrity": "sha512-Pksl0pnDz2l1+L5Ug85NlG6LWrrklN9qkMxN5Mv+1XZ3T6u580dnE6mVaxjJRdcOq4tR17Pc0RqIDZMyVY1FlA==", + "dev": true, + "peer": true, + "dependencies": { + "@types/uuid": "8.3.4", + "class-transformer": "0.5.1", + "reflect-metadata": "0.1.13", + "uuid": "9.0.0" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true, + "peer": true + }, + "node_modules/@cucumber/gherkin-utils/node_modules/commander": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz", + "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==", + "dev": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/@cucumber/html-formatter": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-20.4.0.tgz", + "integrity": "sha512-TnLSXC5eJd8AXHENo69f5z+SixEVtQIf7Q2dZuTpT/Y8AOkilGpGl1MQR1Vp59JIw+fF3EQSUKdf+DAThCxUNg==", + "dev": true, + "peer": true, + "peerDependencies": { + "@cucumber/messages": ">=18" + } + }, + "node_modules/@cucumber/message-streams": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/message-streams/-/message-streams-4.0.1.tgz", + "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", + "dev": true, + "peer": true, + "peerDependencies": { + "@cucumber/messages": ">=17.1.1" + } + }, + "node_modules/@cucumber/messages": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-22.0.0.tgz", + "integrity": "sha512-EuaUtYte9ilkxcKmfqGF9pJsHRUU0jwie5ukuZ/1NPTuHS1LxHPsGEODK17RPRbZHOFhqybNzG2rHAwThxEymg==", + "dev": true, + "peer": true, + "dependencies": { + "@types/uuid": "9.0.1", + "class-transformer": "0.5.1", + "reflect-metadata": "0.1.13", + "uuid": "9.0.0" + } + }, + "node_modules/@cucumber/tag-expressions": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-5.0.1.tgz", + "integrity": "sha512-N43uWud8ZXuVjza423T9ZCIJsaZhFekmakt7S9bvogTxqdVGbRobjR663s0+uW0Rz9e+Pa8I6jUuWtoBLQD2Mw==", + "dev": true, + "peer": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "peer": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "peer": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "peer": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.39.0.tgz", + "integrity": "sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ==", + "dev": true, + "dependencies": { + "playwright": "1.39.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@teppeis/multimaps": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-2.0.0.tgz", + "integrity": "sha512-TL1adzq1HdxUf9WYduLcQ/DNGYiz71U31QRgbnr0Ef1cPyOUOsBojxHVWpFeOSUucB6Lrs0LxFRA14ntgtkc9w==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10.17" + } + }, + "node_modules/@types/node": { + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.0.tgz", + "integrity": "sha512-nekiGu2NDb1BcVofVcEKMIwzlx4NjHlcjhoxxKBNLtz15Y1z7MYf549DFvkHSId02Ax6kGwWntIBPC3l/JZcmw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "peer": true + }, + "node_modules/@types/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==", + "dev": true, + "peer": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "peer": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/assertion-error-formatter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/assertion-error-formatter/-/assertion-error-formatter-3.0.0.tgz", + "integrity": "sha512-6YyAVLrEze0kQ7CmJfUgrLHb+Y7XghmL2Ie7ijVa2Y9ynP3LV+VDiwFk62Dn0qtqbmY0BT0ss6p1xxpiF2PYbQ==", + "dev": true, + "peer": true, + "dependencies": { + "diff": "^4.0.1", + "pad-right": "^0.2.2", + "repeat-string": "^1.6.1" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "peer": true + }, + "node_modules/call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/capital-case": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", + "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", + "dev": true, + "peer": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "dev": true, + "peer": true + }, + "node_modules/cli-table3": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", + "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==", + "dev": true, + "peer": true, + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "peer": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "peer": true + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "peer": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "peer": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "peer": true + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dev": true, + "peer": true, + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-abstract": { + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "peer": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "peer": true, + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "peer": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dev": true, + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "peer": true + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "dev": true, + "peer": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "dev": true, + "peer": true, + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-ansi": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-4.0.1.tgz", + "integrity": "sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "peer": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "peer": true + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/internal-slot": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "dev": true, + "peer": true, + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, + "peer": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "peer": true + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "peer": true + }, + "node_modules/knuth-shuffle-seeded": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz", + "integrity": "sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==", + "dev": true, + "peer": true, + "dependencies": { + "seed-random": "~2.2.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "peer": true + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "peer": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "peer": true + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true, + "peer": true + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "peer": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz", + "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==", + "dev": true, + "peer": true, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/luxon": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz", + "integrity": "sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz", + "integrity": "sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==", + "dev": true, + "peer": true, + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "peer": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "peer": true, + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/nodemon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.1.tgz", + "integrity": "sha512-g9AZ7HmkhQkqXkRc20w+ZfQ73cHLbE8hnPbtaFbFtCumZsjyMhKk9LajQ07U5Ux28lvFjZ5X7HvWR1xzU8jHVw==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "dev": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm-run-all/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/npm-run-all/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/npm-run-all/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/npm-run-all/node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/npm-run-all/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/npm-run-all/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/npm-run-all/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", + "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "peer": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "peer": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pad-right": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/pad-right/-/pad-right-0.2.2.tgz", + "integrity": "sha512-4cy8M95ioIGolCoMmm2cMntGR1lPLEbOMzOKu8bzjuJP6JpzEMQcDHmh7hHLYGgob+nKe1YHFMaG4V59HQa89g==", + "dev": true, + "peer": true, + "dependencies": { + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", + "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "dev": true, + "peer": true, + "dependencies": { + "lru-cache": "^9.1.1 || ^10.0.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/playwright": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.39.0.tgz", + "integrity": "sha512-naE5QT11uC/Oiq0BwZ50gDmy8c8WLPRTEWuSSFVG2egBka/1qMoSqYQcROMT9zLwJ86oPofcTH2jBY/5wWOgIw==", + "dev": true, + "dependencies": { + "playwright-core": "1.39.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-bdd": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/playwright-bdd/-/playwright-bdd-5.4.0.tgz", + "integrity": "sha512-Lj3WinaJqbnOP6YT8gCeJBELg/MY+cQqJOjvFFxhOHoFexMbhHKWM7uZiRrUd51I78cmJr3iBEzoFlev5zHnmg==", + "dev": true, + "dependencies": { + "commander": "^11.1.0", + "fast-glob": "^3.3.1" + }, + "bin": { + "bddgen": "dist/cli/index.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@cucumber/cucumber": ">=9", + "@playwright/test": ">=1.33" + } + }, + "node_modules/playwright-bdd/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, + "node_modules/playwright-core": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.39.0.tgz", + "integrity": "sha512-+k4pdZgs1qiM+OUkSjx96YiKsXsmb59evFoqv8SKO067qBA+Z2s/dCzJij/ZhdQcs2zlTAgRKfeiiLm8PQ2qvw==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "dev": true, + "peer": true + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "peer": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "peer": true, + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==", + "dev": true, + "peer": true + }, + "node_modules/regexp-match-indices": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz", + "integrity": "sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==", + "dev": true, + "peer": true, + "dependencies": { + "regexp-tree": "^0.1.11" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "peer": true, + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg/-/resolve-pkg-2.0.0.tgz", + "integrity": "sha512-+1lzwXehGCXSeryaISr6WujZzowloigEofRB+dj75y9RRa/obVcYgbHJd53tdYw8pvZj8GojXaaENws8Ktw/hQ==", + "dev": true, + "peer": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", + "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "is-regex": "^1.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/seed-random": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/seed-random/-/seed-random-2.2.0.tgz", + "integrity": "sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==", + "dev": true, + "peer": true + }, + "node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", + "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "dev": true + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "dev": true, + "peer": true + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz", + "integrity": "sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "peer": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "peer": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "dev": true, + "peer": true + }, + "node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "peer": true, + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "dev": true, + "peer": true + }, + "node_modules/touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "dependencies": { + "nopt": "~1.0.10" + }, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true, + "peer": true + }, + "node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/upper-case-first": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", + "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", + "dev": true, + "peer": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/util-arity": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/util-arity/-/util-arity-1.1.0.tgz", + "integrity": "sha512-kkyIsXKwemfSy8ZEoaIz06ApApnWsk5hQO0vLjZS6UkBiGiW++Jsyb8vSBoc0WKlffGoGs5yYy/j5pp8zckrFA==", + "dev": true, + "peer": true + }, + "node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "dev": true, + "peer": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "peer": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.4", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "peer": true + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "peer": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "peer": true + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", + "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yup": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.2.0.tgz", + "integrity": "sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ==", + "dev": true, + "peer": true, + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ab57de8 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "photofield-e2e", + "version": "1.0.0", + "description": "Playwright tests for Photofield, see ./ui/ for frontend", + "main": "vetur.config.js", + "directories": { + "doc": "docs" + }, + "scripts": { + "bddgen": "bddgen", + "test": "npx bddgen && npx playwright test", + "watch:bdd": "nodemon -w ./tests -e feature,js,ts --exec npx bddgen", + "watch:pw": "playwright test --ui", + "watch": "run-p watch:*", + "report": "npx playwright show-report", + "steps": "npx bddgen export" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.39.0", + "@types/node": "^20.9.0", + "nodemon": "^3.0.1", + "npm-run-all": "^4.1.5", + "playwright-bdd": "^5.4.0", + "typescript": "^5.2.2" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..63dfaed --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,91 @@ +import { defineConfig, devices } from '@playwright/test'; +import { defineBddConfig } from 'playwright-bdd'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +const testDir = defineBddConfig({ + paths: ['./tests/**/*.feature'], + require: ['./tests/**/*.ts'], + formatOptions: { + // Fix for ERR_UNSUPPORTED_ESM_URL_SCHEME on Windows + snippetSyntax: './node_modules/playwright-bdd/dist/snippets/snippetSyntaxTs.js' + }, + importTestFrom: './tests/fixtures.ts', +}) + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + // testDir: './tests', + testDir, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + // globalTeardown: require.resolve('./tests/teardown.ts'), + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/tests/connection-error.feature b/tests/connection-error.feature new file mode 100644 index 0000000..3eb4987 --- /dev/null +++ b/tests/connection-error.feature @@ -0,0 +1,25 @@ +Feature: Connection Error Message + + Scenario: UI loads, but API is down + Given an empty working directory + When the user opens the home page + Then the page shows a progress bar + Then the page shows "Connection error" + + Scenario: UI loads, API is up intermittently + Given an empty working directory + And a running API + When the user opens the home page + Then the page shows "Photos" + Then the page does not show "Connection error" + And the page shows "No collections" + When the API goes down + And the user waits for 2 seconds + And the user switches away and back to the page + And the user waits for 5 seconds + Then the page shows "Connection error" + When the API comes back up + Then the page shows "Photos" + When the user clicks "Retry" + Then the page does not show "Connecting..." + And the page does not show "Connection error" diff --git a/tests/first-run.feature b/tests/first-run.feature new file mode 100644 index 0000000..ec9be16 --- /dev/null +++ b/tests/first-run.feature @@ -0,0 +1,10 @@ +Feature: First User Experience + + Scenario: Empty Folder + Given an empty working directory + When the user runs the app + Then the app logs "app running" + When the user opens the home page + Then the page shows "Photos" + Then the page does not show "Connection error" + And the page shows "No collections" diff --git a/tests/fixtures.ts b/tests/fixtures.ts new file mode 100644 index 0000000..5cd6953 --- /dev/null +++ b/tests/fixtures.ts @@ -0,0 +1,102 @@ +// Note: import base from playwright-bdd, not from @playwright/test! +import { test as base } from 'playwright-bdd'; +import fs from 'fs/promises'; +import { join } from 'path'; +import { ChildProcess, spawn } from 'child_process'; +import { Page } from '@playwright/test'; + +const LISTEN_REGEX = /local\s+(http:\/\/\S+)/; + +class App { + + public cwd: string; + public stdout: string; + public stderr: string; + public host: string = 'localhost'; + public port: number = 0; + public listenUrl: string = ''; + proc?: ChildProcess; + exitCode: number | null; + uiUrl: string; + + constructor(public page: Page) { + } + + async useTempDir() { + const tmpDir = 'test-tmp/' + // Format like 2021-09-30-12-00-00-000 + const datetime = new Date().toISOString() + .replace(/Z/, '') + .replace(/[:T.]/g, '-'); + const suffix = Math.random().toString(36).substring(2, 8); + const dirName = `test-${datetime}-${suffix}`; + this.cwd = join(process.cwd(), tmpDir, dirName); + await fs.mkdir(this.cwd); + } + + + async run() { + const exe = process.platform === 'win32' ? '.exe' : ''; + const command = join(process.cwd(), './photofield' + exe); + + const address = `${this.host}:${this.port}`; + this.uiUrl = `http://${address}`; + + const env = { + PHOTOFIELD_ADDRESS: address, + }; + + console.log("Running:", command, env); + + this.proc = spawn(command, [], { + cwd: this.cwd, + env, + stdio: 'pipe', + timeout: 60000, + }); + this.proc.stdout!.on('data', (data) => { + console.log(data.toString()); + this.stdout += data.toString(); + }); + this.proc.stderr!.on('data', (data) => { + console.error(data.toString()); + const match = data.toString().match(LISTEN_REGEX); + if (match) { + this.listenUrl = match[1]; + } + this.stderr += data.toString(); + }); + this.proc.on('close', (code) => { + this.exitCode = code; + }); + } + + async goto(path: string) { + await this.page.goto(`${this.listenUrl}${path}`); + } + + async stop() { + this.proc?.kill(); + this.proc = undefined; + // Remove the temporary directory + // await fs.rmdir(this.cwd, { recursive: true }); + } + + async cleanup() { + this.stop(); + // Remove the temporary directory + // if (this.cwd) { + // await fs.rmdir(this.cwd, { recursive: true }); + // } + } + +} + +// export custom test function +export const test = base.extend<{ app: App }>({ + app: async ({ page }, use) => { + const app = new App(page); + await use(app); + await app.cleanup(); + } +}); \ No newline at end of file diff --git a/tests/steps.ts b/tests/steps.ts new file mode 100644 index 0000000..959ada6 --- /dev/null +++ b/tests/steps.ts @@ -0,0 +1,73 @@ +import { Page, expect } from '@playwright/test'; +import { createBdd } from 'playwright-bdd'; +import { test } from './fixtures'; + +const { Given, When, Then } = createBdd(test); + +Given('an empty working directory', async ({ app }) => { + await app.useTempDir(); + console.log("CWD:", app.cwd); +}); + +Given('local ui', async ({ app }) => { + app.uiUrl = 'http://localhost:3000'; +}); + +When('the user runs the app', async ({ app }) => { + await app.run(); +}); + +Then('debug wait {int}', async ({}, ms: number) => { + await new Promise(resolve => setTimeout(resolve, ms)); +}); + +Then('the app logs {string}', async ({ app }, log: string) => { + await expect(async () => { + expect(app.stderr).toContain(log); + }).toPass(); +}); + +Given('a running API', async ({ app }) => { + await app.run(); + await expect(async () => { + expect(app.stderr).toContain("api at :8080/"); + }).toPass(); +}); + +When('the API goes down', async ({ app }) => { + await app.stop(); +}); + +When('the API comes back up', async ({ app }) => { + await app.run(); +}); + +When('the user waits for {int} seconds', async ({ page }, sec: number) => { + await page.waitForTimeout(sec * 1000); +}); + +When('the user opens the home page', async ({ app }) => { + await app.goto("/"); +}); + +Then('the page shows a progress bar', async ({ page }) => { + await expect(page.locator("#content").getByRole('progressbar')).toBeVisible(); +}); + +Then('the page shows {string}', async ({ page }, text) => { + await expect(page.getByText(text)).toBeVisible(); +}); + +Then('the page does not show {string}', async ({ page }, text: string) => { + await expect(page.getByText(text)).not.toBeVisible(); +}); + +When('the user switches away and back to the page', async ({ page }) => { + await page.evaluate(() => { + document.dispatchEvent(new Event('visibilitychange')) + }) +}); + +When('the user clicks {string}', async ({ page }, text: string) => { + await page.getByText(text).click(); +}); diff --git a/tests/teardown.ts b/tests/teardown.ts new file mode 100644 index 0000000..35b5590 --- /dev/null +++ b/tests/teardown.ts @@ -0,0 +1,15 @@ +import { promisify } from "util"; +const exec = promisify(require("child_process").exec); + +async function globalTeardown() { + if (process.platform === "win32") { + await killExiftool(); + } +} + +// Workaround of photofield not cleaning up exiftool.exe properly +async function killExiftool() { + await exec("taskkill /F /IM exiftool.exe"); +} + +export default globalTeardown; From b3a64230b7d392b3946e0c12ed5196469a3bc61c Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 18 Nov 2023 18:29:32 +0100 Subject: [PATCH 03/13] Better loader + error messages --- ui/src/api.js | 20 +++ ui/src/components/CenterMessage.vue | 54 ++++++++ ui/src/components/ErrorBar.vue | 144 ++++++++++++++++++++++ ui/src/components/Home.vue | 61 +++++---- ui/src/components/ResponseLoader.vue | 79 ++++++++++++ ui/src/components/ResponseRetryButton.vue | 42 +++++++ 6 files changed, 374 insertions(+), 26 deletions(-) create mode 100644 ui/src/components/CenterMessage.vue create mode 100644 ui/src/components/ErrorBar.vue create mode 100644 ui/src/components/ResponseLoader.vue create mode 100644 ui/src/components/ResponseRetryButton.vue diff --git a/ui/src/api.js b/ui/src/api.js index c8f82d0..372dbad 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -140,10 +140,17 @@ export function useApi(getUrl, config) { items, })); }; + const errorTime = computed(() => { + if (response.error.value) { + return new Date(); + } + return null; + }); return { ...response, items, itemsMutate, + errorTime, } } @@ -244,6 +251,19 @@ export function useBufferApi(getUrl, config) { return useSWRV(getUrl, bufferFetcher, config); } +async function textFetcher(endpoint) { + const response = await fetch(host + endpoint); + if (!response.ok) { + console.error(response); + throw new Error(response.statusText); + } + return await response.text(); +} + +export function useTextApi(getUrl, config) { + return useSWRV(getUrl, textFetcher, config); +} + export function useTasks() { const intervalMs = 250; const response = useApi( diff --git a/ui/src/components/CenterMessage.vue b/ui/src/components/CenterMessage.vue new file mode 100644 index 0000000..8e6ddef --- /dev/null +++ b/ui/src/components/CenterMessage.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/ui/src/components/ErrorBar.vue b/ui/src/components/ErrorBar.vue new file mode 100644 index 0000000..892001c --- /dev/null +++ b/ui/src/components/ErrorBar.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/ui/src/components/Home.vue b/ui/src/components/Home.vue index 4607db6..5c37caf 100644 --- a/ui/src/components/Home.vue +++ b/ui/src/components/Home.vue @@ -1,8 +1,28 @@ - diff --git a/ui/src/components/ResponseLoader.vue b/ui/src/components/ResponseLoader.vue new file mode 100644 index 0000000..901aba1 --- /dev/null +++ b/ui/src/components/ResponseLoader.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/ui/src/components/ResponseRetryButton.vue b/ui/src/components/ResponseRetryButton.vue new file mode 100644 index 0000000..6492498 --- /dev/null +++ b/ui/src/components/ResponseRetryButton.vue @@ -0,0 +1,42 @@ + + + + + \ No newline at end of file From 3a5883688ac450f7adc8c36da7bb947fe465b907 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 18 Nov 2023 19:10:02 +0100 Subject: [PATCH 04/13] Add local testing support with failing test --- .features-gen/tests/first-run.feature.spec.js | 13 +++++ main.go | 2 +- tests/first-run.feature | 12 +++++ tests/fixtures.ts | 49 +++++++++++++++---- tests/steps.ts | 13 +++-- ui/src/api.js | 43 ++++++++++++---- 6 files changed, 107 insertions(+), 25 deletions(-) diff --git a/.features-gen/tests/first-run.feature.spec.js b/.features-gen/tests/first-run.feature.spec.js index 03d6b1e..429c7f1 100644 --- a/.features-gen/tests/first-run.feature.spec.js +++ b/.features-gen/tests/first-run.feature.spec.js @@ -13,6 +13,19 @@ test.describe("First User Experience", () => { await And("the page shows \"No collections\"", null, { page }); }); + test("Empty Folder + Add Folder", async ({ Given, app, When, Then, page, And }) => { + await Given("an empty working directory", null, { app }); + await When("the user runs the app", null, { app }); + await Then("the app logs \"app running\"", null, { app }); + await When("the user opens the home page", null, { app }); + await Then("the page shows \"Photos\"", null, { page }); + await Then("the page does not show \"Connection error\"", null, { page }); + await And("the page shows \"No collections\"", null, { page }); + await When("the user adds a folder \"vacation\"", null, { app }); + await And("the user clicks \"Retry\"", null, { page }); + await Then("the page does not show \"No collections\"", null, { page }); + }); + }); // == technical section == diff --git a/main.go b/main.go index d2c40fc..f0db132 100644 --- a/main.go +++ b/main.go @@ -1495,7 +1495,7 @@ func main() { }) msg = fmt.Sprintf("app running (api under %s)", apiPrefix) } else { - msg = "api running" + msg = "app running (api only)" } // addExampleScene() diff --git a/tests/first-run.feature b/tests/first-run.feature index ec9be16..ce28fb4 100644 --- a/tests/first-run.feature +++ b/tests/first-run.feature @@ -8,3 +8,15 @@ Feature: First User Experience Then the page shows "Photos" Then the page does not show "Connection error" And the page shows "No collections" + + Scenario: Empty Folder + Add Folder + Given an empty working directory + When the user runs the app + Then the app logs "app running" + When the user opens the home page + Then the page shows "Photos" + Then the page does not show "Connection error" + And the page shows "No collections" + When the user adds a folder "vacation" + And the user clicks "Retry" + Then the page does not show "No collections" diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 5cd6953..fc62c31 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -3,7 +3,7 @@ import { test as base } from 'playwright-bdd'; import fs from 'fs/promises'; import { join } from 'path'; import { ChildProcess, spawn } from 'child_process'; -import { Page } from '@playwright/test'; +import { BrowserContext, Page } from '@playwright/test'; const LISTEN_REGEX = /local\s+(http:\/\/\S+)/; @@ -17,10 +17,13 @@ class App { public listenUrl: string = ''; proc?: ChildProcess; exitCode: number | null; + uiLocal: boolean = false; uiUrl: string; - constructor(public page: Page) { - } + constructor( + public page: Page, + public context: BrowserContext, + ) {} async useTempDir() { const tmpDir = 'test-tmp/' @@ -34,16 +37,21 @@ class App { await fs.mkdir(this.cwd); } + async addDir(dir: string) { + console.log("Adding dir:", dir); + await fs.mkdir(join(this.cwd, dir)); + } async run() { const exe = process.platform === 'win32' ? '.exe' : ''; const command = join(process.cwd(), './photofield' + exe); const address = `${this.host}:${this.port}`; - this.uiUrl = `http://${address}`; const env = { PHOTOFIELD_ADDRESS: address, + PHOTOFIELD_API_PREFIX: '/', + PHOTOFIELD_CORS_ALLOWED_ORIGINS: 'http://localhost:3000', }; console.log("Running:", command, env); @@ -59,12 +67,23 @@ class App { this.stdout += data.toString(); }); this.proc.stderr!.on('data', (data) => { - console.error(data.toString()); - const match = data.toString().match(LISTEN_REGEX); + const msg = data.toString(); + console.error(msg); + if (msg.includes('api only')) { + console.log("API only mode, using local UI") + if (!this.uiUrl) { + this.uiLocal = true; + this.uiUrl = `http://localhost:3000`; + } + } + const match = msg.match(LISTEN_REGEX); if (match) { this.listenUrl = match[1]; + if (!this.uiUrl) { + this.uiUrl = this.listenUrl; + } } - this.stderr += data.toString(); + this.stderr += msg; }); this.proc.on('close', (code) => { this.exitCode = code; @@ -72,7 +91,17 @@ class App { } async goto(path: string) { - await this.page.goto(`${this.listenUrl}${path}`); + if (this.uiLocal) { + await this.context.addCookies([ + { + name: 'photofield-api-host', + value: this.listenUrl, + url: this.uiUrl, + } + ]); + console.log(await this.context.cookies()) + } + await this.page.goto(`${this.uiUrl}${path}`); } async stop() { @@ -94,8 +123,8 @@ class App { // export custom test function export const test = base.extend<{ app: App }>({ - app: async ({ page }, use) => { - const app = new App(page); + app: async ({ page, context }, use) => { + const app = new App(page, context); await use(app); await app.cleanup(); } diff --git a/tests/steps.ts b/tests/steps.ts index 959ada6..860d7f4 100644 --- a/tests/steps.ts +++ b/tests/steps.ts @@ -9,10 +9,6 @@ Given('an empty working directory', async ({ app }) => { console.log("CWD:", app.cwd); }); -Given('local ui', async ({ app }) => { - app.uiUrl = 'http://localhost:3000'; -}); - When('the user runs the app', async ({ app }) => { await app.run(); }); @@ -71,3 +67,12 @@ When('the user switches away and back to the page', async ({ page }) => { When('the user clicks {string}', async ({ page }, text: string) => { await page.getByText(text).click(); }); + +When('the user adds a folder {string}', async ({ app }, name: string) => { + await app.addDir(name); +}); + +When('the user clicks "Retry', async ({ page }) => { + await page.getByRole('button', { name: 'Retry' }).click(); +}); + \ No newline at end of file diff --git a/ui/src/api.js b/ui/src/api.js index 372dbad..da23812 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -3,10 +3,33 @@ import { computed, watch, ref } from "vue"; import qs from "qs"; import { useRetry } from "./use"; -const host = import.meta.env.VITE_API_HOST || "/api"; +let _host = null; +function host() { + if (_host) { + return _host; + } + const cookieHost = getCookie("photofield-api-host"); + if (cookieHost) { + _host = cookieHost; + return _host; + } + _host = import.meta.env.VITE_API_HOST || "/api"; + return _host; +} + +function getCookie(name) { + const cookies = document.cookie.split("; "); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].split("="); + if (cookie[0] === name) { + return cookie[1]; + } + } + return null; +} async function fetcher(endpoint) { - const response = await fetch(host + endpoint); + const response = await fetch(host() + endpoint); if (!response.ok) { console.error(response); throw new Error(response.statusText); @@ -15,7 +38,7 @@ async function fetcher(endpoint) { } export async function get(endpoint, def) { - const response = await fetch(host + endpoint); + const response = await fetch(host() + endpoint); if (!response.ok) { if (def !== undefined) { return def; @@ -27,7 +50,7 @@ export async function get(endpoint, def) { } export async function post(endpoint, body, def) { - const response = await fetch(host + endpoint, { + const response = await fetch(host() + endpoint, { method: "POST", body: JSON.stringify(body), headers: { @@ -108,15 +131,15 @@ export function getTileUrl(sceneId, level, x, y, tileSize, backgroundColor, extr if (backgroundColor) { params.background_color = backgroundColor; } - let url = `${host}/scenes/${sceneId}/tiles?${qs.stringify(params, { arrayFormat: "comma" })}`; + let url = `${host()}/scenes/${sceneId}/tiles?${qs.stringify(params, { arrayFormat: "comma" })}`; return url; } export function getFileUrl(id, filename) { if (!filename) { - return `${host}/files/${id}`; + return `${host()}/files/${id}`; } - return `${host}/files/${id}/original/${filename}`; + return `${host()}/files/${id}/original/${filename}`; } export async function getFileBlob(id) { @@ -124,7 +147,7 @@ export async function getFileBlob(id) { } export function getThumbnailUrl(id, size, filename) { - return `${host}/files/${id}/variants/${size}/${filename}`; + return `${host()}/files/${id}/variants/${size}/${filename}`; } export function useApi(getUrl, config) { @@ -239,7 +262,7 @@ export function useScene({ } async function bufferFetcher(endpoint) { - const response = await fetch(host + endpoint); + const response = await fetch(host() + endpoint); if (!response.ok) { console.error(response); throw new Error(response.statusText); @@ -252,7 +275,7 @@ export function useBufferApi(getUrl, config) { } async function textFetcher(endpoint) { - const response = await fetch(host + endpoint); + const response = await fetch(host() + endpoint); if (!response.ok) { console.error(response); throw new Error(response.statusText); From eb6e4ef669e713f96f41b7c893aebe0574d8de80 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 2 Dec 2023 22:24:59 +0100 Subject: [PATCH 05/13] Autoreload config + many leak fixes --- config.go | 97 +++++++++++++ go.mod | 28 ++-- go.sum | 76 +++++----- internal/fs/watch.go | 193 +++++++++++++++++++++++++ internal/fs/watch_test.go | 136 ++++++++++++++++++ internal/geo/cache.go | 20 ++- internal/geo/geo.go | 5 + internal/image/cache.go | 30 +++- internal/image/database.go | 20 ++- internal/image/decoder.go | 3 + internal/image/exiftool-mostlygeek.go | 1 + internal/image/source.go | 55 ++++---- internal/metrics/metrics.go | 96 +++++++++---- internal/queue/queue.go | 23 +-- internal/scene/sceneSource.go | 6 +- io/cached/cached.go | 8 ++ io/configured/configured.go | 4 + io/exiftool/exiftool.go | 5 + io/ffmpeg/ffmpeg.go | 4 + io/filtered/filtered.go | 4 + io/goexif/goexif.go | 4 + io/goimage/goimage.go | 4 + io/io.go | 15 ++ io/mutex/mutex.go | 4 + io/ristretto/ristretto.go | 74 ++++------ io/sqlite/sqlite.go | 18 ++- io/thumb/thumb.go | 4 + io_test.go | 158 +++++++++++---------- justfile | 10 +- main.go | 196 +++++++++++--------------- reload_test.go | 80 +++++++++++ 31 files changed, 1005 insertions(+), 376 deletions(-) create mode 100644 config.go create mode 100644 internal/fs/watch.go create mode 100644 internal/fs/watch_test.go create mode 100644 reload_test.go diff --git a/config.go b/config.go new file mode 100644 index 0000000..7f16da2 --- /dev/null +++ b/config.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "photofield/internal/clip" + "photofield/internal/collection" + "photofield/internal/fs" + "photofield/internal/geo" + "photofield/internal/image" + "photofield/internal/layout" + "photofield/internal/render" + "photofield/tag" + "strings" + + "github.com/goccy/go-yaml" + "github.com/imdario/mergo" +) + +type AppConfig struct { + Collections []collection.Collection `json:"collections"` + Layout layout.Layout `json:"layout"` + Render render.Render `json:"render"` + Media image.Config `json:"media"` + AI clip.AI `json:"ai"` + Geo geo.Config `json:"geo"` + Tags tag.Config `json:"tags"` + TileRequests TileRequestConfig `json:"tile_requests"` +} + +var CONFIG_FILENAME = "configuration.yaml" + +func watchConfig(dataDir string, callback func(init bool)) { + w, err := fs.NewFileWatcher(filepath.Join(dataDir, CONFIG_FILENAME)) + if err != nil { + log.Fatalln("Unable to watch config", err) + } + go func() { + defer w.Close() + for { + <-w.Events + callback(false) + } + }() + callback(true) +} + +func initDefaults() { + if err := yaml.Unmarshal(defaultsYaml, &defaults); err != nil { + panic(err) + } +} + +func loadConfig(dataDir string) (*AppConfig, error) { + path := filepath.Join(dataDir, CONFIG_FILENAME) + + var appConfig AppConfig + + log.Printf("config path %v", path) + bytes, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("unable to read %s: %w", path, err) + } else if err := yaml.Unmarshal(bytes, &appConfig); err != nil { + return nil, fmt.Errorf("unable to parse %s: %w", path, err) + } else if err := mergo.Merge(&appConfig, defaults); err != nil { + return nil, fmt.Errorf("unable to merge defaults: %w", err) + } + + appConfig.Collections = expandCollections(appConfig.Collections) + for i := range appConfig.Collections { + collection := &appConfig.Collections[i] + collection.GenerateId() + collection.Layout = strings.ToUpper(collection.Layout) + if collection.Limit > 0 && collection.IndexLimit == 0 { + collection.IndexLimit = collection.Limit + } + } + + appConfig.Media.AI = appConfig.AI + appConfig.Media.DataDir = dataDir + appConfig.Tags.Enable = appConfig.Tags.Enable || appConfig.Tags.Enabled + return &appConfig, nil +} + +func expandCollections(collections []collection.Collection) []collection.Collection { + expanded := make([]collection.Collection, 0, len(collections)) + for _, collection := range collections { + if collection.ExpandSubdirs { + expanded = append(expanded, collection.Expand()...) + } else { + expanded = append(expanded, collection) + } + } + return expanded +} diff --git a/go.mod b/go.mod index e1e9569..2fc50e7 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/alecthomas/assert/v2 v2.2.2 github.com/alecthomas/participle/v2 v2.0.0 github.com/deepmap/oapi-codegen v1.8.2 - github.com/dgraph-io/ristretto v0.0.2 + github.com/dgraph-io/ristretto v0.1.2-0.20230929213430-5239be55a219 github.com/docker/go-units v0.4.0 github.com/felixge/fgprof v0.9.1 github.com/go-chi/chi/v5 v5.0.4 @@ -31,16 +31,17 @@ require ( github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d github.com/prometheus/client_golang v1.11.0 github.com/prometheus/client_model v0.2.0 + github.com/rjeczalik/notify v0.9.3 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/sheerun/queue v1.0.1 - github.com/smilyorg/tinygpkg v0.2.0 + github.com/smilyorg/tinygpkg v0.2.1 github.com/tdewolff/canvas v0.0.0-20200504121106-e2600b35c365 github.com/x448/float16 v0.8.4 golang.org/x/exp v0.0.0-20231006140011-7918f672742d golang.org/x/image v0.0.0-20191214001246-9130b4cfad52 golang.org/x/sync v0.4.0 - modernc.org/sqlite v1.21.1 - zombiezen.com/go/sqlite v0.13.0 + modernc.org/sqlite v1.27.0 + zombiezen.com/go/sqlite v0.13.1 ) require ( @@ -49,10 +50,9 @@ require ( github.com/alecthomas/repr v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/buger/jsonparser v1.0.0 // indirect - github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.1.1 // indirect github.com/dsnet/compress v0.0.1 // indirect - github.com/dustin/go-humanize v1.0.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.9.0 // indirect github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect @@ -77,26 +77,24 @@ require ( github.com/prometheus/common v0.26.0 // indirect github.com/prometheus/procfs v0.6.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/stretchr/testify v1.8.1 // indirect github.com/tdewolff/minify/v2 v2.7.1-0.20200112204046-70870d25a935 // indirect github.com/tdewolff/parse/v2 v2.4.2 // indirect github.com/wcharczuk/go-chart v2.0.2-0.20191206192251-962b9abdec2b+incompatible // indirect go.uber.org/atomic v1.7.0 // indirect golang.org/x/mod v0.13.0 // indirect - golang.org/x/sys v0.13.0 // indirect + golang.org/x/sys v0.14.0 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/tools v0.14.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect gonum.org/v1/plot v0.0.0-20190410204940-3a5f52653745 // indirect google.golang.org/protobuf v1.26.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.2.0 // indirect - modernc.org/cc/v3 v3.40.0 // indirect - modernc.org/ccgo/v3 v3.16.13 // indirect - modernc.org/libc v1.22.3 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect + modernc.org/cc/v3 v3.41.0 // indirect + modernc.org/ccgo/v3 v3.16.15 // indirect + modernc.org/libc v1.34.11 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect modernc.org/opt v0.1.3 // indirect modernc.org/strutil v1.1.3 // indirect - modernc.org/token v1.0.1 // indirect + modernc.org/token v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index 5894999..0e0b68d 100644 --- a/go.sum +++ b/go.sum @@ -56,8 +56,6 @@ github.com/ClickHouse/clickhouse-go v1.4.3/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHg github.com/EdlinOrg/prominentcolor v1.0.0 h1:sQNY8Dtsv3PK3J1LbmrDmtlZm9Y9U8Loi1iZIl4YN3Y= github.com/EdlinOrg/prominentcolor v1.0.0/go.mod h1:mYmDsxfcmBz6izH/SqtSzfsUiZdPNPpPgUPKCZq70KQ= github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= -github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/ajstarks/svgo v0.0.0-20200320125537-f189e35d30ca h1:kWzLcty5V2rzOqJM7Tp/MfSX0RMSI1x4IOLApEefYxA= github.com/ajstarks/svgo v0.0.0-20200320125537-f189e35d30ca/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= @@ -114,8 +112,6 @@ github.com/buger/jsonparser v1.0.0 h1:etJTGF5ESxjI0Ic2UaLQs2LQQpa8G9ykQScukbh4L8 github.com/buger/jsonparser v1.0.0/go.mod h1:tgcrVJ81GPSF0mz+0nu1Xaz0fazGPrmmJfJtxjbHhUQ= github.com/cenkalti/backoff/v4 v4.0.2/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= @@ -141,11 +137,11 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/deepmap/oapi-codegen v1.8.2 h1:SegyeYGcdi0jLLrpbCMoJxnUUn8GBXHsvr4rbzjuhfU= github.com/deepmap/oapi-codegen v1.8.2/go.mod h1:YLgSKSDv/bZQB7N4ws6luhozi3cEdRktEqrX88CvjIw= github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= -github.com/dgraph-io/ristretto v0.0.2 h1:a5WaUrDa0qm0YrAAS1tUykT5El3kt62KNZZeMxQn3po= -github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgraph-io/ristretto v0.1.2-0.20230929213430-5239be55a219 h1:IMi+1l3+bcC4VZcrmpuIT2a6gCbXtUxoMzZ1t08jlek= +github.com/dgraph-io/ristretto v0.1.2-0.20230929213430-5239be55a219/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= -github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dhui/dktest v0.3.4/go.mod h1:4m4n6lmXlmVfESth7mzdcv8nBI5mOb5UROPqjM02csU= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v17.12.0-ce-rc1.0.20210128214336-420b1d36250f+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= @@ -155,8 +151,9 @@ github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= -github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -584,6 +581,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qq github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= +github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -605,11 +604,9 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/smilyorg/tinygpkg v0.2.0 h1:1yqvZG6AzLDvpR/wwqaLgGY131syuozj3DyQQLiiPyE= -github.com/smilyorg/tinygpkg v0.2.0/go.mod h1:nDvVmk7GEET7pDdkAAtFXq7RBFCoOzw/nW0xFgqbqAY= +github.com/smilyorg/tinygpkg v0.2.1 h1:QC0h+3Gg4hk0IuqXiJ7FIoPNFw9ubIMpc4xjaiIJ+EE= +github.com/smilyorg/tinygpkg v0.2.1/go.mod h1:RTjUs6sBaxENNmlx7StM0NW8tCZkHFcIQmPJrIcTwsc= github.com/snowflakedb/gosnowflake v1.4.3/go.mod h1:1kyg2XEduwti88V11PKRHImhXLK5WpGiayY6lFNYb98= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v0.0.0-20170901052352-ee1bd8ee15a1/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.1.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= @@ -620,18 +617,14 @@ github.com/spf13/viper v1.0.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7Sr github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tdewolff/canvas v0.0.0-20200504121106-e2600b35c365 h1:iNyEAGvN8yNc5/maTbLhStXRIqp+PSxpjG68KRtU3Y4= github.com/tdewolff/canvas v0.0.0-20200504121106-e2600b35c365/go.mod h1:DCuQBGs+Nm73wH9S/z1tlUKDbAPCGa6W7A/DHU1ENmQ= github.com/tdewolff/minify/v2 v2.7.1-0.20200112204046-70870d25a935 h1:nRG5jPGtwJpQ8KtrqhGVdLAuOnk4YWfNxh4Kx9XMuAw= @@ -817,6 +810,7 @@ golang.org/x/sys v0.0.0-20180224232135-f6cff0780e54/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181031143558-9b800f95dbbc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -881,8 +875,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210521090106-6ca3eb03dfc2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1128,11 +1122,11 @@ lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl modernc.org/b v1.0.0/go.mod h1:uZWcZfRj1BpYzfN9JTerzlNUnnPsV9O2ZA8JsRcubNg= modernc.org/cc v1.0.0/go.mod h1:1Sk4//wdnYJiUIxnW8ddKpaOJCF37yAdqYnkxUpaYxw= modernc.org/cc/v3 v3.32.4/go.mod h1:0R6jl1aZlIl2avnYfbfHBS1QB6/f+16mihBObaBC878= -modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= -modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= +modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= +modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= modernc.org/ccgo/v3 v3.9.2/go.mod h1:gnJpy6NIVqkETT+L5zPsQFj7L2kkhfPMzOghRNv/CFo= -modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= -modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= +modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0= +modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= modernc.org/db v1.0.0/go.mod h1:kYD/cO29L/29RM0hXYl4i3+Q5VojL31kTUVpVJDw0s8= @@ -1144,45 +1138,45 @@ modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= modernc.org/internal v1.0.0/go.mod h1:VUD/+JAkhCpvkUitlEOnhpVxCgsBI90oTzSCRcqQVSM= modernc.org/libc v1.7.13-0.20210308123627-12f642a52bb8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w= modernc.org/libc v1.9.5/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w= -modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY= -modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw= +modernc.org/libc v1.34.11 h1:hQDcIUlSG4QAOkXCIQKkaAOV5ptXvkOx4ddbXzgW2JU= +modernc.org/libc v1.34.11/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= modernc.org/lldb v1.0.0/go.mod h1:jcRvJGWfCGodDZz8BPwiKMJxGJngQ/5DrRapkQnLob8= modernc.org/mathutil v1.0.0/go.mod h1:wU0vUrJsVWBZ4P6e7xtFJEhFSNsfRLJ8H458uRjg03k= modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.0.4/go.mod h1:nV2OApxradM3/OVbs2/0OsP6nPfakXpi50C7dcoHXlc= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/ql v1.0.0/go.mod h1:xGVyrLIatPcO2C1JvI/Co8c0sr6y91HKFNy4pt9JXEY= modernc.org/sortutil v1.1.0/go.mod h1:ZyL98OQHJgH9IEfN71VsamvJgrtRX9Dj2gX+vH86L1k= modernc.org/sqlite v1.10.6/go.mod h1:Z9FEjUtZP4qFEg6/SiADg9XCER7aYy9a/j7Pg9P7CPs= -modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU= -modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI= +modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8= +modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= modernc.org/strutil v1.0.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs= modernc.org/strutil v1.1.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs= modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= modernc.org/tcl v1.5.2/go.mod h1:pmJYOLgpiys3oI4AeAafkcUfE+TKKilminxNyU/+Zlo= -modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws= -modernc.org/tcl v1.15.1/go.mod h1:aEjeGJX2gz1oWKOLDVZ2tnEWLUrIn8H+GFu+akoDhqs= +modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= +modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c= modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= -modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/xc v1.0.0/go.mod h1:mRNCo0bvLjGhHO9WsyuKVU4q0ceiDDDoEeWDJHrNx8I= modernc.org/z v1.0.1-0.20210308123920-1f282aa71362/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA= modernc.org/z v1.0.1/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA= -modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE= -modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ= +modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY= +modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE= modernc.org/zappy v1.0.0/go.mod h1:hHe+oGahLVII/aTTyWK/b53VDHMAGCBYYeZ9sn83HC4= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -zombiezen.com/go/sqlite v0.13.0 h1:iEeyVqcm3fk5PCA8OQBhBxPnqrP4yYuVJBF+XZpSnOE= -zombiezen.com/go/sqlite v0.13.0/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4= +zombiezen.com/go/sqlite v0.13.1 h1:qDzxyWWmMtSSEH5qxamqBFmqA2BLSSbtODi3ojaE02o= +zombiezen.com/go/sqlite v0.13.1/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4= diff --git a/internal/fs/watch.go b/internal/fs/watch.go new file mode 100644 index 0000000..4fc0568 --- /dev/null +++ b/internal/fs/watch.go @@ -0,0 +1,193 @@ +package fs + +import ( + "path/filepath" + "time" + + "github.com/rjeczalik/notify" +) + +type Event struct { + Op Op + Path string + OldPath string +} + +type Op uint32 + +const ( + Update Op = 1 << iota + Remove + Rename +) + +type Watcher struct { + Events chan Event + filename string + c chan notify.EventInfo +} + +func NewFileWatcher(path string) (*Watcher, error) { + w := &Watcher{ + Events: make(chan Event, 100), + c: make(chan notify.EventInfo, 100), + } + dir := filepath.Dir(path) + w.filename = filepath.Base(path) + err := notify.Watch( + dir, + w.c, + notify.Remove, + notify.Rename, + notify.Create, + notify.Write, + ) + if err != nil { + w.Close() + return nil, err + } + go w.run() + return w, nil +} + +func NewRecursiveWatcher(dirs []string) (*Watcher, error) { + w := &Watcher{ + Events: make(chan Event, 100), + c: make(chan notify.EventInfo, 100), + } + for _, dir := range dirs { + err := notify.Watch( + dir+"/...", + w.c, + notify.Remove, + notify.Rename, + notify.Create, + notify.Write, + ) + if err != nil { + w.Close() + return nil, err + } + } + go w.run() + return w, nil +} + +func removePathFromEvents(events []Event, path string) (found bool) { + for i := range events { + e := &events[i] + if e.Path == path { + e.Path = "" + found = true + } + } + return +} + +func (w *Watcher) run() { + // Update events are delayed by 1x - 2x this interval to avoid multiple + // updates for the same file and out of order remove and update events. + interval := 200 * time.Millisecond + ticker := &time.Ticker{ + C: make(chan time.Time), + } + tickerRunning := false + next := make([]Event, 0, 100) + pending := make([]Event, 0, 100) + pendingRenamePath := "" + for { + select { + case <-ticker.C: + // println("tick") + // ticker.Stop() + for i, e := range next { + if e.Path == "" { + continue + } + if i > 0 && next[i-1].Path == e.Path { + continue + } + // println("update", e.Path) + w.Events <- e + } + next = next[:0] + next, pending = pending, next + if len(next) == 0 { + ticker.Stop() + tickerRunning = false + } + case e := <-w.c: + // println("event", e.Path(), e.Event()) + if w.filename != "" && filepath.Base(e.Path()) != w.filename { + // println("skip", e.Path()) + continue + } + switch e.Event() { + case notify.Rename: + if pendingRenamePath != "" { + println("rename", pendingRenamePath, e.Path()) + ev := Event{ + Op: Rename, + Path: pendingRenamePath, + OldPath: e.Path(), + } + pendingRenamePath = "" + w.Events <- ev + + // Remove the previous path (reported second) + // ev := Event{ + // Path: e.Path(), + // Op: Remove, + // } + // if removePathFromEvents(next, ev.Path) { + // continue + // } + // if removePathFromEvents(pending, ev.Path) { + // continue + // } + + // // Add the new path (reported first) + // w.Events <- ev + // ev = Event{ + // Path: pendingRenamePath, + // Op: Update, + // } + // pendingRenamePath = "" + // pending = append(pending, ev) + // if !tickerRunning { + // ticker = time.NewTicker(interval) + // } + } else { + pendingRenamePath = e.Path() + } + + case notify.Create, + notify.Write: + pending = append(pending, Event{ + Op: Update, + Path: e.Path(), + }) + if !tickerRunning { + ticker = time.NewTicker(interval) + } + case notify.Remove: + ev := Event{ + Op: Remove, + Path: e.Path(), + } + println("remove", ev.Path) + if removePathFromEvents(next, ev.Path) { + continue + } + if removePathFromEvents(pending, ev.Path) { + continue + } + w.Events <- ev + } + } + } +} + +func (w *Watcher) Close() { + notify.Stop(w.c) +} diff --git a/internal/fs/watch_test.go b/internal/fs/watch_test.go new file mode 100644 index 0000000..edab91b --- /dev/null +++ b/internal/fs/watch_test.go @@ -0,0 +1,136 @@ +package fs + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestWatcher(t *testing.T) { + dir, err := os.MkdirTemp("", "watcher-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + w, err := NewWatcher([]string{dir}) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + // Create a file + file := filepath.Join(dir, "test.txt") + if err := os.WriteFile(file, []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + + // Wait for the event + select { + case e := <-w.Events: + if e.Path != file || e.Op != Update { + t.Fatalf("unexpected event: %+v", e) + } + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for event") + } + + // Modify the file + if err := os.WriteFile(file, []byte("world"), 0644); err != nil { + t.Fatal(err) + } + + // Wait for the event + select { + case e := <-w.Events: + if e.Path != file || e.Op != Update { + t.Fatalf("unexpected event: %+v", e) + } + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for event") + } + + // Rename the file + newFile := filepath.Join(dir, "test2.txt") + if err := os.Rename(file, newFile); err != nil { + t.Fatal(err) + } + + // Wait for the event + select { + case e := <-w.Events: + if e.Path != newFile || e.OldPath != file || e.Op != Rename { + t.Fatalf("unexpected event: %+v", e) + } + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for event") + } + + // Wait for the events + // select { + // case e := <-w.Events: + // if e.Path != file || e.Op != Remove { + // t.Fatalf("unexpected event: %+v", e) + // } + // case <-time.After(1 * time.Second): + // t.Fatal("timeout waiting for event") + // } + // select { + // case e := <-w.Events: + // if e.Path != newFile || e.Op != Update { + // t.Fatalf("unexpected event: %+v", e) + // } + // case <-time.After(1 * time.Second): + // t.Fatal("timeout waiting for event") + // } + + // Create a directory + dir2 := filepath.Join(dir, "testdir") + if err := os.Mkdir(dir2, 0755); err != nil { + t.Fatal(err) + } + + // Wait for the event + select { + case e := <-w.Events: + if e.Path != dir2 || e.Op != Update { + t.Fatalf("unexpected event: %+v", e) + } + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for event") + } + + // Rename the directory + newDir := filepath.Join(dir, "testdir2") + if err := os.Rename(dir2, newDir); err != nil { + t.Fatal(err) + } + + // Wait for the event + select { + case e := <-w.Events: + if e.Path != newDir || e.OldPath != dir2 || e.Op != Rename { + t.Fatalf("unexpected event: %+v", e) + } + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for event") + } + + // select { + // case e := <-w.Events: + // if e.Path != dir2 || e.Op != Remove { + // t.Fatalf("unexpected event: %+v", e) + // } + // case <-time.After(1 * time.Second): + // t.Fatal("timeout waiting for event") + // } + // select { + // case e := <-w.Events: + // if e.Path != newDir || e.Op != Update { + // t.Fatalf("unexpected event: %+v", e) + // } + // case <-time.After(1 * time.Second): + // t.Fatal("timeout waiting for event") + // } +} diff --git a/internal/geo/cache.go b/internal/geo/cache.go index 910c786..279cb72 100644 --- a/internal/geo/cache.go +++ b/internal/geo/cache.go @@ -11,34 +11,44 @@ import ( ) type Cache struct { - cache *ristretto.Cache + cache *ristretto.Cache[int64, geom.Geometry] } func NewCache() (*Cache, error) { g := &Cache{} - c, err := ristretto.NewCache(&ristretto.Config{ + c, err := ristretto.NewCache(&ristretto.Config[int64, geom.Geometry]{ NumCounters: 100000, // number of keys to track frequency of, 10x max expected key count MaxCost: 64_000_000, // maximum size/cost of cache BufferItems: 64, // number of keys per Get buffer. Metrics: true, - Cost: func(value interface{}) int64 { - return estimateGeometryMemorySize(value.(geom.Geometry)) + Cost: func(g geom.Geometry) int64 { + return estimateGeometryMemorySize(g) }, }) if err != nil { return nil, fmt.Errorf("failed to create geometry cache: %w", err) } + c.Close() metrics.AddRistretto("geometry_cache", c) g.cache = c return g, nil } +func (g *Cache) Close() { + if g == nil || g.cache == nil { + return + } + g.cache.Clear() + g.cache.Close() + g.cache = nil +} + func (g *Cache) Get(fid gpkg.FeatureId) (geom.Geometry, error) { v, ok := g.cache.Get(int64(fid)) if !ok { return geom.Geometry{}, gpkg.ErrNotFound } - return v.(geom.Geometry), nil + return v, nil } func (g *Cache) Set(fid gpkg.FeatureId, geom geom.Geometry) error { diff --git a/internal/geo/geo.go b/internal/geo/geo.go index 4fac787..2970531 100644 --- a/internal/geo/geo.go +++ b/internal/geo/geo.go @@ -123,6 +123,11 @@ func (g *Geo) Close() error { return nil } if g.gp != nil { + c, ok := g.gp.Cache.(*Cache) + if !ok { + return fmt.Errorf("error closing geopackage: cache is not a *Cache") + } + c.Close() err := g.gp.Close() if err != nil { return fmt.Errorf("error closing geopackage: %w", err) diff --git a/internal/image/cache.go b/internal/image/cache.go index eaeb7d4..099df9d 100644 --- a/internal/image/cache.go +++ b/internal/image/cache.go @@ -8,13 +8,13 @@ import ( ) type InfoCache struct { - cache *ristretto.Cache + cache *ristretto.Cache[uint32, Info] } func (c *InfoCache) Get(id ImageId) (Info, bool) { value, found := c.cache.Get((uint32)(id)) if found { - return value.(Info), true + return value, true } return Info{}, false } @@ -29,7 +29,7 @@ func (c *InfoCache) Delete(id ImageId) { } func newInfoCache() InfoCache { - cache, err := ristretto.NewCache(&ristretto.Config{ + cache, err := ristretto.NewCache(&ristretto.Config[uint32, Info]{ NumCounters: 1e6, // number of keys to track frequency of (1M). MaxCost: 1 << 24, // maximum cost of cache (16MB). BufferItems: 64, // number of keys per Get buffer. @@ -44,14 +44,23 @@ func newInfoCache() InfoCache { } } +func (c *InfoCache) Close() { + if c == nil || c.cache == nil { + return + } + c.cache.Clear() + c.cache.Close() + c.cache = nil +} + type PathCache struct { - cache *ristretto.Cache + cache *ristretto.Cache[uint32, string] } func (c *PathCache) Get(id ImageId) (string, bool) { value, found := c.cache.Get((uint32)(id)) if found { - return value.(string), true + return value, true } return "", false } @@ -66,7 +75,7 @@ func (c *PathCache) Delete(id ImageId) { } func newPathCache() PathCache { - cache, err := ristretto.NewCache(&ristretto.Config{ + cache, err := ristretto.NewCache(&ristretto.Config[uint32, string]{ NumCounters: 10e3, // number of keys to track frequency of (10k). MaxCost: 1 << 22, // maximum cost of cache (4MB). BufferItems: 64, // number of keys per Get buffer. @@ -80,3 +89,12 @@ func newPathCache() PathCache { cache: cache, } } + +func (c *PathCache) Close() { + if c == nil || c.cache == nil { + return + } + c.cache.Clear() + c.cache.Close() + c.cache = nil +} diff --git a/internal/image/database.go b/internal/image/database.go index de6245d..8d9c38f 100644 --- a/internal/image/database.go +++ b/internal/image/database.go @@ -141,6 +141,14 @@ func NewDatabase(path string, migrations embed.FS) *Database { return &source } +func (source *Database) Close() { + if source == nil { + return + } + source.pool.Close() + close(source.pending) +} + func (source *Database) open() *sqlite.Conn { conn, err := sqlite.OpenConn(source.path, 0) if err != nil { @@ -308,11 +316,7 @@ func (source *Database) writePendingInfosSqlite() { pendingCompactionTags := tagSet{} defer func() { - err := sqlitex.Execute(conn, "COMMIT;", nil) - source.transactionMutex.Unlock() - if err != nil { - panic(err) - } + source.WaitForCommit() }() commitTicker := &time.Ticker{} @@ -353,7 +357,11 @@ func (source *Database) writePendingInfosSqlite() { source.transactionMutex.Unlock() inTransaction = false - case imageInfo := <-source.pending: + case imageInfo, ok := <-source.pending: + if !ok { + log.Println("database closing") + return + } if !inTransaction { source.transactionMutex.Lock() diff --git a/internal/image/decoder.go b/internal/image/decoder.go index a1d62e1..5c34707 100644 --- a/internal/image/decoder.go +++ b/internal/image/decoder.go @@ -39,6 +39,9 @@ func NewDecoder(exifToolCount int) *Decoder { } func (decoder *Decoder) Close() { + if decoder == nil { + return + } decoder.loader.Close() } diff --git a/internal/image/exiftool-mostlygeek.go b/internal/image/exiftool-mostlygeek.go index 09b7143..9a15031 100644 --- a/internal/image/exiftool-mostlygeek.go +++ b/internal/image/exiftool-mostlygeek.go @@ -197,5 +197,6 @@ func (decoder *ExifToolMostlyGeekLoader) DecodeBytes(path string, tagName string func (decoder *ExifToolMostlyGeekLoader) Close() { if decoder.exifTool != nil { decoder.exifTool.Stop() + decoder.exifTool = nil } } diff --git a/internal/image/source.go b/internal/image/source.go index bfd9cf0..6ba6052 100644 --- a/internal/image/source.go +++ b/internal/image/source.go @@ -23,7 +23,6 @@ import ( "github.com/docker/go-units" "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" ) var ErrNotFound = errors.New("not found") @@ -146,13 +145,14 @@ type Source struct { decoder *Decoder database *Database + imageCache *ristretto.Ristretto imageInfoCache InfoCache pathCache PathCache metadataQueue queue.Queue contentsQueue queue.Queue - thumbnailSources []io.ReadDecoder + thumbnailSources []io.ReadDecoderSource thumbnailGenerators io.Sources thumbnailSink *sqlite.Source @@ -169,43 +169,36 @@ func NewSource(config Config, migrations embed.FS, migrationsThumbs embed.FS, ge source.pathCache = newPathCache() source.Geo = geo - source.SourceLatencyHistogram = promauto.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: metrics.Namespace, - Name: "source_latency", - Buckets: []float64{500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, 150000, 200000, 250000, 500000, 1000000, 2000000, 5000000, 10000000}, - }, + source.SourceLatencyHistogram = metrics.AddHistogram( + "source_latency", + []float64{500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, 150000, 200000, 250000, 500000, 1000000, 2000000, 5000000, 10000000}, []string{"source"}, ) - source.SourceLatencyAbsDiffHistogram = promauto.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: metrics.Namespace, - Name: "source_latency_abs_diff", - Buckets: []float64{50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, 200000, 500000, 1000000}, - }, + source.SourceLatencyAbsDiffHistogram = metrics.AddHistogram( + "source_latency_abs_diff", + []float64{50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, 200000, 500000, 1000000}, []string{"source"}, ) - source.SourcePerOriginalMegapixelLatencyHistogram = promauto.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: metrics.Namespace, - Name: "source_per_original_megapixel_latency", - Buckets: []float64{500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, 150000, 200000, 250000, 500000, 1000000, 2000000, 5000000, 10000000}, - }, + source.SourcePerOriginalMegapixelLatencyHistogram = metrics.AddHistogram( + "source_per_original_megapixel_latency", + []float64{500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, 150000, 200000, 250000, 500000, 1000000, 2000000, 5000000, 10000000}, []string{"source"}, ) - source.SourcePerResizedMegapixelLatencyHistogram = promauto.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: metrics.Namespace, - Name: "source_per_resized_megapixel_latency", - Buckets: []float64{500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, 150000, 200000, 250000, 500000, 1000000, 2000000, 5000000, 10000000}, - }, + source.SourcePerResizedMegapixelLatencyHistogram = metrics.AddHistogram( + "source_per_resized_megapixel_latency", + []float64{500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, 150000, 200000, 250000, 500000, 1000000, 2000000, 5000000, 10000000}, []string{"source"}, ) + source.imageCache = ristretto.New() env := SourceEnvironment{ SourceTypes: config.SourceTypes, FFmpegPath: ffmpeg.FindPath(), Migrations: migrationsThumbs, - ImageCache: ristretto.New(), + ImageCache: source.imageCache, DataDir: config.DataDir, } @@ -224,7 +217,7 @@ func NewSource(config Config, migrations embed.FS, migrationsThumbs embed.FS, ge log.Fatalf("failed to create thumbnail sources: %s", err) } for _, s := range tsrcs { - rd, ok := s.(io.ReadDecoder) + rd, ok := s.(io.ReadDecoderSource) if !ok { log.Fatalf("source %s does not implement io.ReadDecoder", s.Name()) } @@ -251,6 +244,8 @@ func NewSource(config Config, migrations embed.FS, migrationsThumbs embed.FS, ge log.Printf("skipping load info") } else { + source.Clip = config.AI + source.metadataQueue = queue.Queue{ ID: "index_metadata", Name: "index metadata", @@ -259,9 +254,6 @@ func NewSource(config Config, migrations embed.FS, migrationsThumbs embed.FS, ge } go source.metadataQueue.Run() - source.Clip = config.AI - // } - source.contentsQueue = queue.Queue{ ID: "index_contents", Name: "index contents", @@ -269,7 +261,6 @@ func NewSource(config Config, migrations embed.FS, migrationsThumbs embed.FS, ge WorkerCount: 8, } go source.contentsQueue.Run() - } return &source @@ -281,6 +272,14 @@ func (source *Source) Vacuum() error { func (source *Source) Close() { source.decoder.Close() + source.database.Close() + source.imageCache.Close() + source.imageInfoCache.Close() + source.pathCache.Close() + source.Sources.Close() + source.thumbnailSink.Close() + source.metadataQueue.Close() + source.contentsQueue.Close() } func (source *Source) IsSupportedImage(path string) bool { diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index b280ff3..f400a3f 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -1,6 +1,8 @@ package metrics import ( + "sync" + "github.com/dgraph-io/ristretto" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -9,59 +11,105 @@ import ( var Namespace = "pf" +var mutex sync.Mutex +var gauges = make(map[string]prometheus.GaugeFunc) +var counters = make(map[string]prometheus.CounterFunc) +var manualCounters = make(map[string]prometheus.Counter) +var histograms = make(map[string]*prometheus.HistogramVec) + func addGauge(name string, function func() float64) { - promauto.NewGaugeFunc(prometheus.GaugeOpts{ + if _, ok := gauges[name]; ok { + // Gauge already exists, remove it before adding a new one + prometheus.Unregister(gauges[name]) + } + gauge := promauto.NewGaugeFunc(prometheus.GaugeOpts{ Namespace: Namespace, Name: name, - }, - function, - ) + }, function) + gauges[name] = gauge } -func addCounterUint64(name string, function func() uint64) { - promauto.NewCounterFunc(prometheus.CounterOpts{ +func addCounter(name string, function func() float64) { + if _, ok := counters[name]; ok { + // Counter already exists, remove it before adding a new one + prometheus.Unregister(counters[name]) + } + counter := promauto.NewCounterFunc(prometheus.CounterOpts{ Namespace: Namespace, Name: name, - }, - func() float64 { return float64(function()) }, - ) + }, function) + counters[name] = counter +} + +func addCounterUint64(name string, function func() uint64) { + addCounter(name, func() float64 { + return float64(function()) + }) } -func addGaugeUint64(name string, function func() uint64) { - promauto.NewGaugeFunc(prometheus.GaugeOpts{ +func addManualCounter(name string) prometheus.Counter { + if _, ok := manualCounters[name]; ok { + // Counter already exists, remove it before adding a new one + prometheus.Unregister(manualCounters[name]) + } + counter := promauto.NewCounter(prometheus.CounterOpts{ Namespace: Namespace, Name: name, - }, - func() float64 { return float64(function()) }, - ) + }) + manualCounters[name] = counter + return counter } -func AddQueue(name string, queue *queue.Queue) { - promauto.NewGaugeFunc(prometheus.GaugeOpts{ - Namespace: Namespace, - Name: name + "_pending", - }, func() float64 { +type Queue struct { + Done prometheus.Counter +} + +func AddQueue(name string, queue *queue.Queue) Queue { + mutex.Lock() + defer mutex.Unlock() + addGauge(name+"_pending", func() float64 { return float64(queue.Length()) }) + return Queue{ + Done: addManualCounter(name + "_done"), + } } -func AddRistretto(name string, cache *ristretto.Cache) { +func AddRistretto[K any, V any](name string, cache *ristretto.Cache[K, V]) { + mutex.Lock() + defer mutex.Unlock() addGauge(name+"_ratio", cache.Metrics.Ratio) addCounterUint64(name+"_hits", cache.Metrics.Hits) addCounterUint64(name+"_misses", cache.Metrics.Misses) addCounterUint64(name+"_cost_added", cache.Metrics.CostAdded) addCounterUint64(name+"_cost_evicted", cache.Metrics.CostEvicted) - addGaugeUint64(name+"_cost_active", func() uint64 { - return cache.Metrics.CostAdded() - cache.Metrics.CostEvicted() + addGauge(name+"_cost_active", func() float64 { + return float64(cache.Metrics.CostAdded() - cache.Metrics.CostEvicted()) }) addCounterUint64(name+"_keys_added", cache.Metrics.KeysAdded) addCounterUint64(name+"_keys_evicted", cache.Metrics.KeysEvicted) addCounterUint64(name+"_keys_updated", cache.Metrics.KeysUpdated) - addGaugeUint64(name+"_keys_active", func() uint64 { - return cache.Metrics.KeysAdded() - cache.Metrics.KeysEvicted() + addGauge(name+"_keys_active", func() float64 { + return float64(cache.Metrics.KeysAdded() - cache.Metrics.KeysEvicted()) }) addCounterUint64(name+"_sets_dropped", cache.Metrics.SetsDropped) addCounterUint64(name+"_sets_rejected", cache.Metrics.SetsRejected) addCounterUint64(name+"_gets_kept", cache.Metrics.GetsKept) addCounterUint64(name+"_gets_dropped", cache.Metrics.GetsDropped) } + +func AddHistogram(name string, buckets []float64, labelNames []string) *prometheus.HistogramVec { + mutex.Lock() + defer mutex.Unlock() + if existingHistogram, ok := histograms[name]; ok { + // Histogram already exists, unregister it before adding a new one + prometheus.Unregister(existingHistogram) + } + histogram := promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: Namespace, + Name: name, + Buckets: buckets, + }, labelNames) + histograms[name] = histogram + return histogram +} diff --git a/internal/queue/queue.go b/internal/queue/queue.go index 16897c1..b344025 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -5,8 +5,6 @@ import ( "photofield/internal/metrics" "time" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" "github.com/sheerun/queue" ) @@ -16,22 +14,20 @@ type Queue struct { Name string Worker func(<-chan interface{}) WorkerCount int + Stop chan bool } func (q *Queue) Run() { if q.queue == nil { q.queue = queue.New() + q.Stop = make(chan bool) } loadCount := 0 lastLoadCount := 0 lastLogTime := time.Now() logInterval := 2 * time.Second - metrics.AddQueue(q.ID, q.queue) - var doneCounter = promauto.NewCounter(prometheus.CounterOpts{ - Namespace: metrics.Namespace, - Name: q.ID + "_done", - }) + m := metrics.AddQueue(q.ID, q.queue) logging := false @@ -52,11 +48,12 @@ func (q *Queue) Run() { item := q.queue.Pop() if item == nil { log.Printf("%s queue stopping\n", q.Name) + close(q.Stop) return } items <- item } - doneCounter.Inc() + m.Done.Inc() now := time.Now() elapsed := now.Sub(lastLogTime) @@ -87,6 +84,16 @@ func (q *Queue) Run() { } } +func (q *Queue) Close() { + if q == nil || q.queue == nil { + return + } + q.queue.Clean() + q.queue.Append(nil) + <-q.Stop + q.queue = nil +} + func (q *Queue) Length() int { if q.queue == nil { return 0 diff --git a/internal/scene/sceneSource.go b/internal/scene/sceneSource.go index b82801a..1b630e7 100644 --- a/internal/scene/sceneSource.go +++ b/internal/scene/sceneSource.go @@ -22,7 +22,7 @@ type SceneSource struct { DefaultScene render.Scene maxSize int64 - sceneCache *ristretto.Cache + sceneCache *ristretto.Cache[string, *render.Scene] scenes sync.Map } @@ -48,7 +48,7 @@ func NewSceneSource() *SceneSource { source := SceneSource{ maxSize: 1 << 26, // 67 MB } - source.sceneCache, err = ristretto.NewCache(&ristretto.Config{ + source.sceneCache, err = ristretto.NewCache(&ristretto.Config[string, *render.Scene]{ NumCounters: 10000, // number of keys to track frequency of, 10x max expected key count MaxCost: 1 << 50, // maximum size/cost of cache, managed externally BufferItems: 64, // number of keys per Get buffer. @@ -197,7 +197,7 @@ func (source *SceneSource) pruneScenes() { func (source *SceneSource) GetSceneById(id string, imageSource *image.Source) *render.Scene { value, found := source.sceneCache.Get(id) if found { - return value.(*render.Scene) + return value } stored, loaded := source.scenes.Load(id) diff --git a/io/cached/cached.go b/io/cached/cached.go index af6a38b..4e5eb85 100644 --- a/io/cached/cached.go +++ b/io/cached/cached.go @@ -2,6 +2,7 @@ package cached import ( "context" + "errors" "fmt" "photofield/io" "photofield/io/ristretto" @@ -18,6 +19,13 @@ type Cached struct { loading singleflight.Group } +func (c *Cached) Close() error { + return errors.Join( + c.Source.Close(), + c.Cache.Close(), + ) +} + func (c *Cached) Name() string { return c.Source.Name() } diff --git a/io/configured/configured.go b/io/configured/configured.go index 61c2a14..5064ec3 100644 --- a/io/configured/configured.go +++ b/io/configured/configured.go @@ -58,6 +58,10 @@ func New(name string, cost Cost, source io.Source) *Configured { return &c } +func (c *Configured) Close() error { + return c.Source.Close() +} + func (c *Configured) Name() string { return c.NameStr } diff --git a/io/exiftool/exiftool.go b/io/exiftool/exiftool.go index 9e671b4..26d94d9 100644 --- a/io/exiftool/exiftool.go +++ b/io/exiftool/exiftool.go @@ -34,6 +34,11 @@ func New(tag string) *Exif { return &e } +func (e Exif) Close() error { + e.exifTool.Stop() + return nil +} + func (e Exif) Name() string { return fmt.Sprintf("exiftool-%s", e.Tag) } diff --git a/io/ffmpeg/ffmpeg.go b/io/ffmpeg/ffmpeg.go index 0db3264..4f695af 100644 --- a/io/ffmpeg/ffmpeg.go +++ b/io/ffmpeg/ffmpeg.go @@ -68,6 +68,10 @@ func (f FFmpeg) Name() string { return fmt.Sprintf("ffmpeg-%dx%d-%s%s", f.Width, f.Height, fit, found) } +func (f FFmpeg) Close() error { + return nil +} + func (f FFmpeg) DisplayName() string { return "FFmpeg JPEG" } diff --git a/io/filtered/filtered.go b/io/filtered/filtered.go index 87080b3..8aa11a5 100644 --- a/io/filtered/filtered.go +++ b/io/filtered/filtered.go @@ -16,6 +16,10 @@ type Filtered struct { Extensions []string } +func (f *Filtered) Close() error { + return f.Source.Close() +} + func (f *Filtered) Name() string { return f.Source.Name() } diff --git a/io/goexif/goexif.go b/io/goexif/goexif.go index 0cd0707..eb72c49 100644 --- a/io/goexif/goexif.go +++ b/io/goexif/goexif.go @@ -22,6 +22,10 @@ type Exif struct { Fit io.AspectRatioFit } +func (e Exif) Close() error { + return nil +} + func (e Exif) Name() string { return "goexif" } diff --git a/io/goimage/goimage.go b/io/goimage/goimage.go index 530106e..cb77c83 100644 --- a/io/goimage/goimage.go +++ b/io/goimage/goimage.go @@ -21,6 +21,10 @@ type Image struct { Decoder func(goio.Reader) (image.Image, error) } +func (o Image) Close() error { + return nil +} + func (o Image) Name() string { return "original" } diff --git a/io/io.go b/io/io.go index f26c3e2..2196248 100644 --- a/io/io.go +++ b/io/io.go @@ -108,6 +108,7 @@ type Source interface { GetDurationEstimate(original Size) time.Duration Exists(ctx context.Context, id ImageId, path string) bool Get(ctx context.Context, id ImageId, path string) Result + Close() error } type Sink interface { @@ -127,6 +128,11 @@ type ReadDecoder interface { Decoder } +type ReadDecoderSource interface { + Source + ReadDecoder +} + type Sources []Source // Original @@ -178,6 +184,15 @@ func (sources Sources) EstimateCost(original Size, target Size) SourceCosts { return costs } +func (sources Sources) Close() { + for _, s := range sources { + err := s.Close() + if err != nil { + panic(err) + } + } +} + type SourceCost struct { Source EstimatedArea int64 diff --git a/io/mutex/mutex.go b/io/mutex/mutex.go index e95574e..94f9316 100644 --- a/io/mutex/mutex.go +++ b/io/mutex/mutex.go @@ -13,6 +13,10 @@ type Mutex struct { loading sync.Map } +func (m Mutex) Close() error { + return m.Source.Close() +} + type loadingResult struct { result io.Result loaded chan struct{} diff --git a/io/ristretto/ristretto.go b/io/ristretto/ristretto.go index 2e6f4dd..574353b 100644 --- a/io/ristretto/ristretto.go +++ b/io/ristretto/ristretto.go @@ -19,7 +19,7 @@ import ( ) type Ristretto struct { - cache *drist.Cache + cache *drist.Cache[IdWithName, io.Result] } type IdWithSize struct { @@ -38,7 +38,8 @@ func (ids IdWithSize) String() string { func New() *Ristretto { maxSizeBytes := int64(256000000) - cache, err := drist.NewCache(&drist.Config{ + + cache, err := drist.NewCache(&drist.Config[IdWithName, io.Result]{ NumCounters: 1e6, // number of keys to track frequency of MaxCost: maxSizeBytes, // maximum cost of cache BufferItems: 64, // number of keys per Get buffer @@ -55,70 +56,55 @@ func New() *Ristretto { } } -func keyToHash(key interface{}) (uint64, uint64) { - switch k := key.(type) { - case IdWithSize: - a, b := uint64(k.Id), (uint64(k.Size.X)<<32)|(uint64(k.Size.Y)) - fmt.Printf("%x %x\n", a, b) - return uint64(k.Id), (uint64(k.Size.X) << 32) | (uint64(k.Size.Y)) - case IdWithName: - // a, b := uint64(k.Id), z.MemHashString(k.Name) - // fmt.Printf("%x %x\n", a, b) - // b = 0 - // return a, b - str := fmt.Sprintf("%d %s", k.Id, k.Name) - return z.KeyToHash(str) +func (r *Ristretto) Close() error { + if r == nil || r.cache == nil { + return nil } + r.cache.Clear() + r.cache.Close() + r.cache = nil + return nil +} - return z.KeyToHash(key) - // ids, ok := key.(IdWithSize) - // if ok { - - // } - // return +func keyToHash(k IdWithName) (uint64, uint64) { + str := fmt.Sprintf("%d %s", k.Id, k.Name) + return z.KeyToHash(str) } -func (r Ristretto) Name() string { +func (r *Ristretto) Name() string { return "ristretto" } -func (r Ristretto) Size(size io.Size) io.Size { +func (r *Ristretto) Size(size io.Size) io.Size { return io.Size{} } -func (r Ristretto) GetDurationEstimate(size io.Size) time.Duration { +func (r *Ristretto) GetDurationEstimate(size io.Size) time.Duration { return 80 * time.Nanosecond } -func (r Ristretto) Get(ctx context.Context, id io.ImageId, path string) io.Result { - value, found := r.cache.Get(uint32(id)) - if found { - return value.(io.Result) - } - return io.Result{} -} - -func (r Ristretto) GetWithSize(ctx context.Context, ids IdWithSize) io.Result { - value, found := r.cache.Get(ids) +func (r *Ristretto) Get(ctx context.Context, id io.ImageId, path string) io.Result { + idn := IdWithName{Id: id} + value, found := r.cache.Get(idn) if found { - return value.(io.Result) + return value } return io.Result{} } -func (r Ristretto) GetWithName(ctx context.Context, id io.ImageId, name string) io.Result { +func (r *Ristretto) GetWithName(ctx context.Context, id io.ImageId, name string) io.Result { idn := IdWithName{ Id: id, Name: name, } value, found := r.cache.Get(idn) if found { - return value.(io.Result) + return value } return io.Result{} } -func (r Ristretto) SetWithName(ctx context.Context, id io.ImageId, name string, v io.Result) bool { +func (r *Ristretto) SetWithName(ctx context.Context, id io.ImageId, name string, v io.Result) bool { idn := IdWithName{ Id: id, Name: name, @@ -126,16 +112,12 @@ func (r Ristretto) SetWithName(ctx context.Context, id io.ImageId, name string, return r.cache.SetWithTTL(idn, v, 0, 10*time.Minute) } -func (r Ristretto) Set(ctx context.Context, id io.ImageId, path string, v io.Result) bool { - return r.cache.SetWithTTL(uint32(id), v, 0, 10*time.Minute) -} - -func (r Ristretto) SetWithSize(ctx context.Context, ids IdWithSize, v io.Result) bool { - return r.cache.SetWithTTL(ids, v, 0, 10*time.Minute) +func (r *Ristretto) Set(ctx context.Context, id io.ImageId, path string, v io.Result) bool { + idn := IdWithName{Id: id} + return r.cache.SetWithTTL(idn, v, 0, 10*time.Minute) } -func cost(value interface{}) int64 { - r := value.(io.Result) +func cost(r io.Result) int64 { img := r.Image if img == nil { return 1 diff --git a/io/sqlite/sqlite.go b/io/sqlite/sqlite.go index 0f60420..5695bf7 100644 --- a/io/sqlite/sqlite.go +++ b/io/sqlite/sqlite.go @@ -32,6 +32,7 @@ type Source struct { path string pool *sqlitex.Pool pending chan Thumb + closed bool } type Thumb struct { @@ -94,6 +95,15 @@ func New(path string, migrations embed.FS) *Source { return &source } +func (s *Source) Close() error { + if s == nil || s.closed { + return nil + } + s.closed = true + close(s.pending) + return s.pool.Close() +} + func setPragma(conn *sqlite.Conn, name string, value interface{}) error { sql := fmt.Sprintf("PRAGMA %s = %v;", name, value) return sqlitex.ExecuteTransient(conn, sql, &sqlitex.ExecOptions{}) @@ -147,7 +157,8 @@ func (s *Source) init() { func (s *Source) migrate(migrations embed.FS) { dbsource, err := httpfs.New(http.FS(migrations), "db/migrations-thumbs") if err != nil { - log.Fatalf("failed to create migrate source: %v", err) + log.Printf("migrations not found, skipping: %v", err) + return } url := fmt.Sprintf("sqlite://%v", filepath.ToSlash(s.path)) m, err := migrate.NewWithSourceInstance( @@ -208,6 +219,11 @@ func (s *Source) writePending() { c := s.pool.Get(context.Background()) defer s.pool.Put(c) + if c == nil { + log.Println("database write unable to get connection, stopping") + return + } + insert := c.Prep(` INSERT OR REPLACE INTO thumb256(id, created_at_unix, data) VALUES (?, ?, ?);`) diff --git a/io/thumb/thumb.go b/io/thumb/thumb.go index 7546806..a8f1dae 100644 --- a/io/thumb/thumb.go +++ b/io/thumb/thumb.go @@ -96,6 +96,10 @@ func New( return t } +func (t *Thumb) Close() error { + return nil +} + func (t Thumb) Name() string { return fmt.Sprintf("thumb-%dx%d-%s", t.Width, t.Height, t.ThumbName) } diff --git a/io_test.go b/io_test.go index 89ed253..aad4621 100644 --- a/io_test.go +++ b/io_test.go @@ -25,83 +25,6 @@ var dir = "photos/" // var dir = "E:/photos/" -var cache = ristretto.New() -var goimg goimage.Image - -var ffmpegPath = ffmpeg.FindPath() - -var sources = io.Sources{ - // cache, - sqlite.New(path.Join(dir, "../data/photofield.thumbs.db"), embed.FS{}), - goexif.Exif{}, - thumb.New( - "S", - "{{.Dir}}@eaDir/{{.Filename}}/SYNOPHOTO_THUMB_S.jpg", - io.FitInside, - 120, - 120, - ), - thumb.New( - "SM", - "{{.Dir}}@eaDir/{{.Filename}}/SYNOPHOTO_THUMB_SM.jpg", - io.FitOutside, - 240, - 240, - ), - thumb.New( - "M", - "{{.Dir}}@eaDir/{{.Filename}}/SYNOPHOTO_THUMB_M.jpg", - io.FitOutside, - 320, - 320, - ), - thumb.New( - "B", - "{{.Dir}}@eaDir/{{.Filename}}/SYNOPHOTO_THUMB_B.jpg", - io.FitInside, - 640, - 640, - ), - thumb.New( - "XL", - "{{.Dir}}@eaDir/{{.Filename}}/SYNOPHOTO_THUMB_XL.jpg", - io.FitOutside, - 1280, - 1280, - ), - goimg, - ffmpeg.FFmpeg{ - Path: ffmpegPath, - Width: 128, - Height: 128, - Fit: io.FitInside, - }, - ffmpeg.FFmpeg{ - Path: ffmpegPath, - Width: 256, - Height: 256, - Fit: io.FitInside, - }, - ffmpeg.FFmpeg{ - Path: ffmpegPath, - Width: 512, - Height: 512, - Fit: io.FitInside, - }, - ffmpeg.FFmpeg{ - Path: ffmpegPath, - Width: 1280, - Height: 1280, - Fit: io.FitInside, - }, - ffmpeg.FFmpeg{ - Path: ffmpegPath, - Width: 4096, - Height: 4096, - Fit: io.FitInside, - }, -} - var files = []struct { name string path string @@ -114,7 +37,86 @@ var files = []struct { // {name: "cow", path: "formats/cow.avif"}, } +func createTestSources() io.Sources { + var goimg goimage.Image + var ffmpegPath = ffmpeg.FindPath() + return io.Sources{ + // cache, + sqlite.New(path.Join(dir, "../data/photofield.thumbs.db"), embed.FS{}), + goexif.Exif{}, + thumb.New( + "S", + "{{.Dir}}@eaDir/{{.Filename}}/SYNOPHOTO_THUMB_S.jpg", + io.FitInside, + 120, + 120, + ), + thumb.New( + "SM", + "{{.Dir}}@eaDir/{{.Filename}}/SYNOPHOTO_THUMB_SM.jpg", + io.FitOutside, + 240, + 240, + ), + thumb.New( + "M", + "{{.Dir}}@eaDir/{{.Filename}}/SYNOPHOTO_THUMB_M.jpg", + io.FitOutside, + 320, + 320, + ), + thumb.New( + "B", + "{{.Dir}}@eaDir/{{.Filename}}/SYNOPHOTO_THUMB_B.jpg", + io.FitInside, + 640, + 640, + ), + thumb.New( + "XL", + "{{.Dir}}@eaDir/{{.Filename}}/SYNOPHOTO_THUMB_XL.jpg", + io.FitOutside, + 1280, + 1280, + ), + goimg, + ffmpeg.FFmpeg{ + Path: ffmpegPath, + Width: 128, + Height: 128, + Fit: io.FitInside, + }, + ffmpeg.FFmpeg{ + Path: ffmpegPath, + Width: 256, + Height: 256, + Fit: io.FitInside, + }, + ffmpeg.FFmpeg{ + Path: ffmpegPath, + Width: 512, + Height: 512, + Fit: io.FitInside, + }, + ffmpeg.FFmpeg{ + Path: ffmpegPath, + Width: 1280, + Height: 1280, + Fit: io.FitInside, + }, + ffmpeg.FFmpeg{ + Path: ffmpegPath, + Width: 4096, + Height: 4096, + Fit: io.FitInside, + }, + } +} + func BenchmarkSources(b *testing.B) { + var cache = ristretto.New() + var goimg goimage.Image + sources := createTestSources() ctx := context.Background() for _, bm := range files { bm := bm @@ -161,6 +163,7 @@ func BenchmarkSources(b *testing.B) { } func TestCost(t *testing.T) { + sources := createTestSources() cases := []struct { zoom int o io.Size @@ -193,6 +196,7 @@ func TestCost(t *testing.T) { } func TestCostSmallest(t *testing.T) { + sources := createTestSources() costs := sources.EstimateCost(io.Size{X: 5472, Y: 3648}, io.Size{X: 1, Y: 1}) costs.Sort() for i, c := range costs { diff --git a/justfile b/justfile index 6298338..c44cd7b 100644 --- a/justfile +++ b/justfile @@ -53,7 +53,7 @@ ui: cd ui && npm run dev watch: - watchexec --exts go,yaml -r just run + watchexec --exts go -r just run watch-build: watchexec --exts go,yaml -r 'just build && echo build successful' @@ -89,3 +89,11 @@ prof-heap: filepath=profiles/heap/heap-$(date +"%F-%H%M%S").pprof && \ curl --progress-bar -o $filepath {{pprof}}/heap && \ go tool pprof -http=: $filepath + +prof-reload: + go test -benchmem -benchtime 10s '-run=^$' -bench '^BenchmarkReload$' photofield + +test-reload: + mkdir -p profiles/ + go test -v '-run=^TestReloadLeaks$' photofield + go tool pprof -http ':' -diff_base profiles/reload-before.pprof profiles/reload-after.pprof diff --git a/main.go b/main.go index f0db132..cfd2a7e 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,6 @@ import ( "mime" "net" "path" - "path/filepath" "regexp" "runtime" "sort" @@ -40,20 +39,17 @@ import ( chirender "github.com/go-chi/render" "github.com/grafana/pyroscope-go" "github.com/hako/durafmt" - "github.com/imdario/mergo" "github.com/joho/godotenv" "github.com/lpar/gzipped" "github.com/tdewolff/canvas" "github.com/tdewolff/canvas/rasterizer" - "github.com/goccy/go-yaml" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promhttp" io_prometheus_client "github.com/prometheus/client_model/go" - "photofield/internal/clip" "photofield/internal/codec" "photofield/internal/collection" "photofield/internal/geo" @@ -106,6 +102,7 @@ type TilePool struct { } var imageSource *image.Source +var globalGeo *geo.Geo var sceneSource *scene.SceneSource var collections []collection.Collection @@ -326,16 +323,16 @@ func popBestTileRequest() (bool, TileRequest) { func processTileRequests(concurrency int) { for i := 0; i < concurrency; i++ { - go func() { + go func(i int) { for { ok, request := popBestTileRequest() if !ok { - panic("Mismatching tileRequestsIn and tileRequestsOut") + log.Printf("tile request worker %v exiting", i) } request.Process <- struct{}{} <-request.Done } - }() + }(i) } } @@ -1061,29 +1058,6 @@ type TileRequestConfig struct { LogStats bool `json:"log_stats"` } -type AppConfig struct { - Collections []collection.Collection `json:"collections"` - Layout layout.Layout `json:"layout"` - Render render.Render `json:"render"` - Media image.Config `json:"media"` - AI clip.AI `json:"ai"` - Geo geo.Config `json:"geo"` - Tags tag.Config `json:"tags"` - TileRequests TileRequestConfig `json:"tile_requests"` -} - -func expandCollections(collections *[]collection.Collection) { - expanded := make([]collection.Collection, 0) - for _, collection := range *collections { - if collection.ExpandSubdirs { - expanded = append(expanded, collection.Expand()...) - } else { - expanded = append(expanded, collection) - } - } - *collections = expanded -} - func indexCollection(collection *collection.Collection) (task Task, existing bool) { task = newFileIndexTask(collection) stored, existing := globalTasks.LoadOrStore(task.Id, task) @@ -1109,38 +1083,6 @@ func indexCollection(collection *collection.Collection) (task Task, existing boo return } -func loadConfiguration(path string) AppConfig { - - var appConfig AppConfig - - log.Printf("config path %v", path) - bytes, err := os.ReadFile(path) - if err != nil { - log.Printf("unable to open %s, using defaults (%s)\n", path, err.Error()) - appConfig = defaults - } else if err := yaml.Unmarshal(bytes, &appConfig); err != nil { - log.Printf("unable to parse %s, using defaults (%s)\n", path, err.Error()) - appConfig = defaults - } else if err := mergo.Merge(&appConfig, defaults); err != nil { - panic("unable to merge configuration with defaults") - } - - expandCollections(&appConfig.Collections) - for i := range appConfig.Collections { - collection := &appConfig.Collections[i] - collection.GenerateId() - collection.Layout = strings.ToUpper(collection.Layout) - if collection.Limit > 0 && collection.IndexLimit == 0 { - collection.IndexLimit = collection.Limit - } - } - - appConfig.Media.AI = appConfig.AI - appConfig.Tags.Enable = appConfig.Tags.Enable || appConfig.Tags.Enabled - - return appConfig -} - func addExampleScene() { sceneConfig := defaultSceneConfig sceneConfig.Scene.Id = "Tqcqtc6h69" @@ -1236,6 +1178,66 @@ func benchmarkSources(collection *collection.Collection, seed int64, sampleSize bench.BenchmarkSources(seed, sources, samples, count) } +func applyConfig(appConfig *AppConfig) { + if globalGeo != nil { + err := globalGeo.Close() + if err != nil { + log.Printf("unable to close geo: %v", err) + } + globalGeo = nil + } + + if imageSource != nil { + imageSource.Close() + imageSource = nil + } + + if tileRequestConfig.Concurrency > 0 { + close(tileRequestsOut) + } + + if len(appConfig.Collections) > 0 { + defaultSceneConfig.Collection = appConfig.Collections[0] + } + collections = appConfig.Collections + defaultSceneConfig.Layout = appConfig.Layout + defaultSceneConfig.Render = appConfig.Render + tileRequestConfig = appConfig.TileRequests + tagsEnabled = appConfig.Tags.Enable + + var err error + globalGeo, err = geo.New( + appConfig.Geo, + GeoFs, + ) + if err != nil { + log.Printf("geo disabled: %v", err) + } else { + log.Printf("%v", globalGeo.String()) + } + + imageSource = image.NewSource(appConfig.Media, migrations, migrationsThumbs, nil) + if tileRequestConfig.Concurrency > 0 { + log.Printf("request concurrency %v", tileRequestConfig.Concurrency) + tileRequestsOut = make(chan struct{}, 10000) + processTileRequests(tileRequestConfig.Concurrency) + } + + extensions := strings.Join(appConfig.Media.ListExtensions, ", ") + log.Printf("extensions %v", extensions) + + log.Printf("%v collections", len(collections)) + for i := range collections { + collection := &collections[i] + collection.UpdateStatus(imageSource) + indexedAgo := "N/A" + if collection.IndexedAt != nil { + indexedAgo = durafmt.Parse(time.Since(*collection.IndexedAt)).LimitFirstN(1).String() + } + log.Printf(" %v - %v files indexed %v ago", collection.Name, collection.IndexedCount, indexedAgo) + } +} + func main() { var err error @@ -1301,47 +1303,11 @@ func main() { }) } - if err := yaml.Unmarshal(defaultsYaml, &defaults); err != nil { - panic(err) - } - + initDefaults() dataDir, exists := os.LookupEnv("PHOTOFIELD_DATA_DIR") if !exists { dataDir = "." } - configurationPath := filepath.Join(dataDir, "configuration.yaml") - - appConfig := loadConfiguration(configurationPath) - appConfig.Media.DataDir = dataDir - tagsEnabled = appConfig.Tags.Enable - - if len(appConfig.Collections) > 0 { - defaultSceneConfig.Collection = appConfig.Collections[0] - } - collections = appConfig.Collections - defaultSceneConfig.Layout = appConfig.Layout - defaultSceneConfig.Render = appConfig.Render - tileRequestConfig = appConfig.TileRequests - - geo, err := geo.New( - appConfig.Geo, - GeoFs, - ) - if err != nil { - log.Printf("geo disabled: %v", err) - } else { - log.Printf("%v", geo.String()) - } - imageSource = image.NewSource(appConfig.Media, migrations, migrationsThumbs, geo) - defer imageSource.Close() - - if *vacuumFlag { - err := imageSource.Vacuum() - if err != nil { - panic(err) - } - return - } sceneSource = scene.NewSceneSource() @@ -1360,18 +1326,24 @@ func main() { } sceneSource.DefaultScene = defaultSceneConfig.Scene - extensions := strings.Join(appConfig.Media.ListExtensions, ", ") - log.Printf("extensions %v", extensions) + watchConfig(dataDir, func(init bool) { + if !init { + log.Printf("config change detected, reloading") + } + appConfig, err := loadConfig(dataDir) + if err != nil { + log.Printf("unable to load configuration: %v", err) + return + } + applyConfig(appConfig) + }) - log.Printf("%v collections", len(collections)) - for i := range collections { - collection := &collections[i] - collection.UpdateStatus(imageSource) - indexedAgo := "N/A" - if collection.IndexedAt != nil { - indexedAgo = durafmt.Parse(time.Since(*collection.IndexedAt)).LimitFirstN(1).String() + if *vacuumFlag { + err := imageSource.Vacuum() + if err != nil { + panic(err) } - log.Printf(" %v - %v files indexed %v ago", collection.Name, collection.IndexedCount, indexedAgo) + return } if *benchFlag { @@ -1420,12 +1392,6 @@ func main() { fmt.Printf("priority,start,end,latency\n") } - tileRequestsOut = make(chan struct{}, 10000) - if tileRequestConfig.Concurrency > 0 { - log.Printf("request concurrency %v", tileRequestConfig.Concurrency) - processTileRequests(tileRequestConfig.Concurrency) - } - r := chi.NewRouter() // r.Use(middleware.Logger) diff --git a/reload_test.go b/reload_test.go new file mode 100644 index 0000000..d82174c --- /dev/null +++ b/reload_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "log" + "os" + "runtime" + "runtime/pprof" + "testing" +) + +func TestReloadLeaks(t *testing.T) { + n := 10 + maxObjectsDiff := int64(1000) + dataDir := "./data" + initDefaults() + appConfig, err := loadConfig(dataDir) + if err != nil { + log.Printf("unable to load configuration: %v", err) + return + } + + applyConfig(appConfig) + + var memStats runtime.MemStats + + runtime.GC() + runtime.GC() + runtime.ReadMemStats(&memStats) + initialObjects := memStats.HeapObjects + initialSize := memStats.HeapAlloc + + // Capture heap profile before reloading + beforeProfile, err := os.Create("profiles/reload-before.pprof") + if err != nil { + log.Printf("unable to create heap profile: %v", err) + return + } + pprof.WriteHeapProfile(beforeProfile) + beforeProfile.Close() + + for i := 0; i < n; i++ { + applyConfig(appConfig) + + runtime.GC() + runtime.ReadMemStats(&memStats) + objectsDiff := int64(memStats.HeapObjects) - int64(initialObjects) + sizeDiff := int64(memStats.HeapAlloc) - int64(initialSize) + log.Printf("after %v reloads, %v objects leaked, %.2f per reload, %v bytes leaked, %.2f per reload", i+1, objectsDiff, float64(objectsDiff)/float64(i+1), sizeDiff, float64(sizeDiff)/float64(i+1)) + } + + runtime.ReadMemStats(&memStats) + objectsDiff := int64(memStats.HeapObjects) - int64(initialObjects) + if objectsDiff > maxObjectsDiff { + t.Errorf("after %v reloads, %v objects leaked, %.2f per reload", n, objectsDiff, float64(objectsDiff)/float64(n)) + } + + // Capture heap profile after reloading + afterProfile, err := os.Create("profiles/reload-after.pprof") + if err != nil { + log.Printf("unable to create heap profile: %v", err) + return + } + pprof.WriteHeapProfile(afterProfile) + afterProfile.Close() + +} + +func BenchmarkReload(b *testing.B) { + dataDir := "./data" + initDefaults() + appConfig, err := loadConfig(dataDir) + if err != nil { + log.Printf("unable to load configuration: %v", err) + return + } + + for i := 0; i < b.N; i++ { + applyConfig(appConfig) + } +} From a33c828fdaa07bc1cf36840d874700af2c3dc983 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 3 Dec 2023 19:42:55 +0100 Subject: [PATCH 06/13] Autoupdate expanded dirs --- .../tests/connection-error.feature.spec.js | 1 - .features-gen/tests/first-run.feature.spec.js | 2 +- config.go | 121 +++++++++++++----- internal/fs/watch.go | 41 +++++- main.go | 38 ++---- tests/connection-error.feature | 1 - tests/first-run.feature | 5 +- tests/fixtures.ts | 18 +-- tests/steps.ts | 26 ++-- 9 files changed, 171 insertions(+), 82 deletions(-) diff --git a/.features-gen/tests/connection-error.feature.spec.js b/.features-gen/tests/connection-error.feature.spec.js index b4e5a4d..5d62b69 100644 --- a/.features-gen/tests/connection-error.feature.spec.js +++ b/.features-gen/tests/connection-error.feature.spec.js @@ -6,7 +6,6 @@ test.describe("Connection Error Message", () => { test("UI loads, but API is down", async ({ Given, app, When, Then, page }) => { await Given("an empty working directory", null, { app }); await When("the user opens the home page", null, { app }); - await Then("the page shows a progress bar", null, { page }); await Then("the page shows \"Connection error\"", null, { page }); }); diff --git a/.features-gen/tests/first-run.feature.spec.js b/.features-gen/tests/first-run.feature.spec.js index 429c7f1..0e5f36c 100644 --- a/.features-gen/tests/first-run.feature.spec.js +++ b/.features-gen/tests/first-run.feature.spec.js @@ -19,9 +19,9 @@ test.describe("First User Experience", () => { await Then("the app logs \"app running\"", null, { app }); await When("the user opens the home page", null, { app }); await Then("the page shows \"Photos\"", null, { page }); - await Then("the page does not show \"Connection error\"", null, { page }); await And("the page shows \"No collections\"", null, { page }); await When("the user adds a folder \"vacation\"", null, { app }); + await And("waits a second", null, { page }); await And("the user clicks \"Retry\"", null, { page }); await Then("the page does not show \"No collections\"", null, { page }); }); diff --git a/config.go b/config.go index 7f16da2..9d8f078 100644 --- a/config.go +++ b/config.go @@ -20,31 +20,81 @@ import ( ) type AppConfig struct { - Collections []collection.Collection `json:"collections"` - Layout layout.Layout `json:"layout"` - Render render.Render `json:"render"` - Media image.Config `json:"media"` - AI clip.AI `json:"ai"` - Geo geo.Config `json:"geo"` - Tags tag.Config `json:"tags"` - TileRequests TileRequestConfig `json:"tile_requests"` + Collections []collection.Collection `json:"collections"` + ExpandedPaths []string `json:"-"` + Layout layout.Layout `json:"layout"` + Render render.Render `json:"render"` + Media image.Config `json:"media"` + AI clip.AI `json:"ai"` + Geo geo.Config `json:"geo"` + Tags tag.Config `json:"tags"` + TileRequests TileRequestConfig `json:"tile_requests"` } var CONFIG_FILENAME = "configuration.yaml" -func watchConfig(dataDir string, callback func(init bool)) { +func watchConfig(dataDir string, callback func(appConfig *AppConfig)) { w, err := fs.NewFileWatcher(filepath.Join(dataDir, CONFIG_FILENAME)) if err != nil { log.Fatalln("Unable to watch config", err) } + + var expandWatcher *fs.Watcher + var collectionsChanged chan fs.Event + var appConfig *AppConfig + reloadConfig := func() { + appConfig, err = loadConfig(dataDir) + if err != nil { + log.Fatalln("Unable to load config", err) + } + expandWatcher.Close() + if len(appConfig.ExpandedPaths) > 0 { + expandWatcher, err = fs.NewPathsWatcher(appConfig.ExpandedPaths) + if err != nil { + log.Fatalln("Unable to watch expanded paths", err) + } + collectionsChanged = expandWatcher.Events + } + callback(appConfig) + } + + reloadConfig() go func() { defer w.Close() for { - <-w.Events - callback(false) + select { + case <-w.Events: + log.Println("config changed, reloading") + case e := <-collectionsChanged: + switch e.Op { + case fs.Update, fs.Rename: + if info, err := os.Stat(e.Path); err != nil || !info.IsDir() { + // Updated or renamed item was not a dir + continue + } + case fs.Remove: + removed := false + for _, collection := range appConfig.Collections { + for _, dir := range collection.Dirs { + if dir == e.Path { + removed = true + break + } + } + if removed { + break + } + } + if !removed { + // Removed item was not a collection dir + continue + } + } + log.Println("collection changed, reloading") + } + reloadConfig() } }() - callback(true) } func initDefaults() { @@ -60,15 +110,36 @@ func loadConfig(dataDir string) (*AppConfig, error) { log.Printf("config path %v", path) bytes, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("unable to read %s: %w", path, err) - } else if err := yaml.Unmarshal(bytes, &appConfig); err != nil { - return nil, fmt.Errorf("unable to parse %s: %w", path, err) - } else if err := mergo.Merge(&appConfig, defaults); err != nil { - return nil, fmt.Errorf("unable to merge defaults: %w", err) + if err == nil { + if err := yaml.Unmarshal(bytes, &appConfig); err != nil { + return nil, fmt.Errorf("unable to parse %s: %w", path, err) + } else if err := mergo.Merge(&appConfig, defaults); err != nil { + return nil, fmt.Errorf("unable to merge defaults: %w", err) + } + } else { + log.Printf("config read failed (using defaults) for %s: %v", path, err) + appConfig = defaults + } + + // Expand collections + expanded := make([]collection.Collection, 0, len(appConfig.Collections)) + expandedDirs := make(map[string]bool) // Track deduplicated dirs + for _, collection := range appConfig.Collections { + if collection.ExpandSubdirs { + for _, dir := range collection.Dirs { + expandedDirs[dir] = true + } + expanded = append(expanded, collection.Expand()...) + } else { + expanded = append(expanded, collection) + } + } + appConfig.Collections = expanded + appConfig.ExpandedPaths = make([]string, 0, len(expandedDirs)) + for dir := range expandedDirs { + appConfig.ExpandedPaths = append(appConfig.ExpandedPaths, dir) } - appConfig.Collections = expandCollections(appConfig.Collections) for i := range appConfig.Collections { collection := &appConfig.Collections[i] collection.GenerateId() @@ -83,15 +154,3 @@ func loadConfig(dataDir string) (*AppConfig, error) { appConfig.Tags.Enable = appConfig.Tags.Enable || appConfig.Tags.Enabled return &appConfig, nil } - -func expandCollections(collections []collection.Collection) []collection.Collection { - expanded := make([]collection.Collection, 0, len(collections)) - for _, collection := range collections { - if collection.ExpandSubdirs { - expanded = append(expanded, collection.Expand()...) - } else { - expanded = append(expanded, collection) - } - } - return expanded -} diff --git a/internal/fs/watch.go b/internal/fs/watch.go index 4fc0568..dcc9af8 100644 --- a/internal/fs/watch.go +++ b/internal/fs/watch.go @@ -50,6 +50,29 @@ func NewFileWatcher(path string) (*Watcher, error) { return w, nil } +func NewPathsWatcher(paths []string) (*Watcher, error) { + w := &Watcher{ + Events: make(chan Event, 100), + c: make(chan notify.EventInfo, 100), + } + for _, path := range paths { + err := notify.Watch( + path, + w.c, + notify.Remove, + notify.Rename, + notify.Create, + notify.Write, + ) + if err != nil { + w.Close() + return nil, err + } + } + go w.run() + return w, nil +} + func NewRecursiveWatcher(dirs []string) (*Watcher, error) { w := &Watcher{ Events: make(chan Event, 100), @@ -117,6 +140,9 @@ func (w *Watcher) run() { tickerRunning = false } case e := <-w.c: + if e == nil { + return + } // println("event", e.Path(), e.Event()) if w.filename != "" && filepath.Base(e.Path()) != w.filename { // println("skip", e.Path()) @@ -125,7 +151,7 @@ func (w *Watcher) run() { switch e.Event() { case notify.Rename: if pendingRenamePath != "" { - println("rename", pendingRenamePath, e.Path()) + // println("rename", pendingRenamePath, e.Path()) ev := Event{ Op: Rename, Path: pendingRenamePath, @@ -175,7 +201,7 @@ func (w *Watcher) run() { Op: Remove, Path: e.Path(), } - println("remove", ev.Path) + // println("remove", ev.Path) if removePathFromEvents(next, ev.Path) { continue } @@ -189,5 +215,14 @@ func (w *Watcher) run() { } func (w *Watcher) Close() { - notify.Stop(w.c) + if w == nil { + return + } + if w.c != nil { + notify.Stop(w.c) + close(w.c) + } + if w.Events != nil { + close(w.Events) + } } diff --git a/main.go b/main.go index cfd2a7e..ec484f9 100644 --- a/main.go +++ b/main.go @@ -477,10 +477,14 @@ func (*Api) GetCollections(w http.ResponseWriter, r *http.Request) { collection := &collections[i] collection.UpdateStatus(imageSource) } + items := collections + if items == nil { + items = make([]collection.Collection, 0) + } respond(w, r, http.StatusOK, struct { Items []collection.Collection `json:"items"` }{ - Items: collections, + Items: items, }) } @@ -645,20 +649,14 @@ func (*Api) GetCapabilities(w http.ResponseWriter, r *http.Request) { if docsUrl == "" { docsUrl = "/docs/usage" } - respond(w, r, http.StatusOK, openapi.Capabilities{ - Search: openapi.Capability{ - Supported: imageSource.AI.Available(), - }, - Tags: openapi.Capability{ - Supported: tagsEnabled, - }, - Docs: openapi.DocsCapability{ - Capability: openapi.Capability{ - Supported: docsUrl != "", - }, - Url: docsUrl, - }, - }) + capabilities := openapi.Capabilities{} + if imageSource != nil { + capabilities.Search.Supported = imageSource.AI.Available() + } + capabilities.Tags.Supported = tagsEnabled + capabilities.Docs.Supported = docsUrl != "" + capabilities.Docs.Url = docsUrl + respond(w, r, http.StatusOK, capabilities) } func (*Api) GetScenesSceneIdTiles(w http.ResponseWriter, r *http.Request, sceneId openapi.SceneId, params openapi.GetScenesSceneIdTilesParams) { @@ -1326,15 +1324,7 @@ func main() { } sceneSource.DefaultScene = defaultSceneConfig.Scene - watchConfig(dataDir, func(init bool) { - if !init { - log.Printf("config change detected, reloading") - } - appConfig, err := loadConfig(dataDir) - if err != nil { - log.Printf("unable to load configuration: %v", err) - return - } + watchConfig(dataDir, func(appConfig *AppConfig) { applyConfig(appConfig) }) diff --git a/tests/connection-error.feature b/tests/connection-error.feature index 3eb4987..5ffa18a 100644 --- a/tests/connection-error.feature +++ b/tests/connection-error.feature @@ -3,7 +3,6 @@ Feature: Connection Error Message Scenario: UI loads, but API is down Given an empty working directory When the user opens the home page - Then the page shows a progress bar Then the page shows "Connection error" Scenario: UI loads, API is up intermittently diff --git a/tests/first-run.feature b/tests/first-run.feature index ce28fb4..93161a3 100644 --- a/tests/first-run.feature +++ b/tests/first-run.feature @@ -11,12 +11,15 @@ Feature: First User Experience Scenario: Empty Folder + Add Folder Given an empty working directory + When the user runs the app Then the app logs "app running" + When the user opens the home page Then the page shows "Photos" - Then the page does not show "Connection error" And the page shows "No collections" + When the user adds a folder "vacation" + And waits a second And the user clicks "Retry" Then the page does not show "No collections" diff --git a/tests/fixtures.ts b/tests/fixtures.ts index fc62c31..7effc0d 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -5,7 +5,7 @@ import { join } from 'path'; import { ChildProcess, spawn } from 'child_process'; import { BrowserContext, Page } from '@playwright/test'; -const LISTEN_REGEX = /local\s+(http:\/\/\S+)/; +const LISTEN_REGEX = /local\s+http:\/\/(\S+)/; class App { @@ -14,11 +14,11 @@ class App { public stderr: string; public host: string = 'localhost'; public port: number = 0; - public listenUrl: string = ''; + public listenHost: string = ''; proc?: ChildProcess; exitCode: number | null; - uiLocal: boolean = false; - uiUrl: string; + uiLocal: boolean = true; + uiUrl: string = "http://localhost:3000"; constructor( public page: Page, @@ -49,7 +49,7 @@ class App { const address = `${this.host}:${this.port}`; const env = { - PHOTOFIELD_ADDRESS: address, + PHOTOFIELD_ADDRESS: this.listenHost || address, PHOTOFIELD_API_PREFIX: '/', PHOTOFIELD_CORS_ALLOWED_ORIGINS: 'http://localhost:3000', }; @@ -78,9 +78,9 @@ class App { } const match = msg.match(LISTEN_REGEX); if (match) { - this.listenUrl = match[1]; + this.listenHost = match[1]; if (!this.uiUrl) { - this.uiUrl = this.listenUrl; + this.uiUrl = "http://" + this.listenHost; } } this.stderr += msg; @@ -95,8 +95,8 @@ class App { await this.context.addCookies([ { name: 'photofield-api-host', - value: this.listenUrl, - url: this.uiUrl, + value: "http://" + (this.listenHost || "localhost:99999"), + url: this.uiUrl || "http://localhost:3000", } ]); console.log(await this.context.cookies()) diff --git a/tests/steps.ts b/tests/steps.ts index 860d7f4..6f3df94 100644 --- a/tests/steps.ts +++ b/tests/steps.ts @@ -13,20 +13,10 @@ When('the user runs the app', async ({ app }) => { await app.run(); }); -Then('debug wait {int}', async ({}, ms: number) => { - await new Promise(resolve => setTimeout(resolve, ms)); -}); - -Then('the app logs {string}', async ({ app }, log: string) => { - await expect(async () => { - expect(app.stderr).toContain(log); - }).toPass(); -}); - Given('a running API', async ({ app }) => { await app.run(); await expect(async () => { - expect(app.stderr).toContain("api at :8080/"); + expect(app.stderr).toContain("app running"); }).toPass(); }); @@ -38,10 +28,24 @@ When('the API comes back up', async ({ app }) => { await app.run(); }); +Then('debug wait {int}', async ({}, ms: number) => { + await new Promise(resolve => setTimeout(resolve, ms)); +}); + +Then('the app logs {string}', async ({ app }, log: string) => { + await expect(async () => { + expect(app.stderr).toContain(log); + }).toPass(); +}); + When('the user waits for {int} seconds', async ({ page }, sec: number) => { await page.waitForTimeout(sec * 1000); }); +When('waits a second', async ({ page }) => { + await page.waitForTimeout(1000); +}); + When('the user opens the home page', async ({ app }) => { await app.goto("/"); }); From 1c0d23e755d317392f76467bde7490446be5acd1 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 3 Dec 2023 22:01:12 +0100 Subject: [PATCH 07/13] Graceful database shutdown --- .features-gen/tests/cleanup.feature.spec.js | 31 ++++++++++++++++++ .features-gen/tests/first-run.feature.spec.js | 18 +++++++++++ internal/image/database.go | 8 +++++ internal/image/source.go | 5 +++ main.go | 17 ++++++++++ tests/cleanup.feature | 21 ++++++++++++ tests/configs/three-collections.yaml | 12 +++++++ tests/first-run.feature | 20 ++++++++++++ tests/fixtures.ts | 6 +++- tests/steps.ts | 32 +++++++++++++++++-- 10 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 .features-gen/tests/cleanup.feature.spec.js create mode 100644 tests/cleanup.feature create mode 100644 tests/configs/three-collections.yaml diff --git a/.features-gen/tests/cleanup.feature.spec.js b/.features-gen/tests/cleanup.feature.spec.js new file mode 100644 index 0000000..b79098c --- /dev/null +++ b/.features-gen/tests/cleanup.feature.spec.js @@ -0,0 +1,31 @@ +/** Generated from: tests\cleanup.feature */ +import { test } from "..\\..\\tests\\fixtures.ts"; + +test.describe("Cleanup", () => { + + test("Database files are cleaned up on exit", async ({ Given, app, When, Then, And }) => { + await Given("an empty working directory", null, { app }); + await When("the user runs the app", null, { app }); + await Then("the app logs \"app running\"", null, { app }); + await And("the file \"photofield.cache.db\" exists", null, { app }); + await And("the file \"photofield.cache.db-shm\" exists", null, { app }); + await And("the file \"photofield.cache.db-wal\" exists", null, { app }); + await And("the file \"photofield.thumbs.db\" exists", null, { app }); + await And("the file \"photofield.thumbs.db-shm\" exists", null, { app }); + await And("the file \"photofield.thumbs.db-wal\" exists", null, { app }); + await When("the user stops the app", null, { app }); + await Then("the file \"photofield.cache.db\" exists", null, { app }); + await And("the file \"photofield.cache.db-shm\" does not exist", null, { app }); + await And("the file \"photofield.cache.db-wal\" does not exist", null, { app }); + await And("the file \"photofield.thumbs.db\" exists", null, { app }); + await And("the file \"photofield.thumbs.db-shm\" does not exist", null, { app }); + await And("the file \"photofield.thumbs.db-wal\" does not exist", null, { app }); + }); + +}); + +// == technical section == + +test.use({ + $test: ({}, use) => use(test), +}); \ No newline at end of file diff --git a/.features-gen/tests/first-run.feature.spec.js b/.features-gen/tests/first-run.feature.spec.js index 0e5f36c..dff16a1 100644 --- a/.features-gen/tests/first-run.feature.spec.js +++ b/.features-gen/tests/first-run.feature.spec.js @@ -26,6 +26,24 @@ test.describe("First User Experience", () => { await Then("the page does not show \"No collections\"", null, { page }); }); + test("Preconfigured Basic", async ({ Given, app, And, When, Then, page }) => { + await Given("an empty working directory", null, { app }); + await And("the config \"configs/three-collections.yaml\"", null, { app }); + await When("the user runs the app", null, { app }); + await Then("the app logs \"app running\"", null, { app }); + await And("the app logs \"config path configuration.yaml\"", null, { app }); + await And("the app logs \"test123\"", null, { app }); + await And("the app logs \"test456\"", null, { app }); + await And("the app logs \"test789\"", null, { app }); + await When("the user opens the home page", null, { app }); + await Then("the page shows \"Photos\"", null, { page }); + await When("the user opens the home page", null, { app }); + await Then("the page shows \"Photos\"", null, { page }); + await And("the page shows \"test123\"", null, { page }); + await And("the page shows \"test456\"", null, { page }); + await And("the page shows \"test789\"", null, { page }); + }); + }); // == technical section == diff --git a/internal/image/database.go b/internal/image/database.go index 8d9c38f..e7e7096 100644 --- a/internal/image/database.go +++ b/internal/image/database.go @@ -360,6 +360,14 @@ func (source *Database) writePendingInfosSqlite() { case imageInfo, ok := <-source.pending: if !ok { log.Println("database closing") + if inTransaction { + err := sqlitex.Execute(conn, "COMMIT;", nil) + if err != nil { + panic(err) + } + source.transactionMutex.Unlock() + inTransaction = false + } return } diff --git a/internal/image/source.go b/internal/image/source.go index 6ba6052..c4ac9ac 100644 --- a/internal/image/source.go +++ b/internal/image/source.go @@ -282,6 +282,11 @@ func (source *Source) Close() { source.contentsQueue.Close() } +func (source *Source) Shutdown() { + source.database.Close() + source.thumbnailSink.Close() +} + func (source *Source) IsSupportedImage(path string) bool { supportedImage := false pathExt := strings.ToLower(filepath.Ext(path)) diff --git a/main.go b/main.go index ec484f9..b7f0952 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "math/rand" "mime" "net" + "os/signal" "path" "regexp" "runtime" @@ -22,6 +23,7 @@ import ( "strconv" "strings" "sync" + "syscall" "testing" "time" @@ -1236,6 +1238,19 @@ func applyConfig(appConfig *AppConfig) { } } +func listenForShutdown() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + go func() { + <-signals + log.Printf("shutdown requested") + if imageSource != nil { + imageSource.Shutdown() + } + os.Exit(0) + }() +} + func main() { var err error @@ -1324,6 +1339,8 @@ func main() { } sceneSource.DefaultScene = defaultSceneConfig.Scene + listenForShutdown() + watchConfig(dataDir, func(appConfig *AppConfig) { applyConfig(appConfig) }) diff --git a/tests/cleanup.feature b/tests/cleanup.feature new file mode 100644 index 0000000..3c9df14 --- /dev/null +++ b/tests/cleanup.feature @@ -0,0 +1,21 @@ +Feature: Cleanup + + Scenario: Database files are cleaned up on exit + Given an empty working directory + + When the user runs the app + Then the app logs "app running" + And the file "photofield.cache.db" exists + And the file "photofield.cache.db-shm" exists + And the file "photofield.cache.db-wal" exists + And the file "photofield.thumbs.db" exists + And the file "photofield.thumbs.db-shm" exists + And the file "photofield.thumbs.db-wal" exists + + When the user stops the app + Then the file "photofield.cache.db" exists + And the file "photofield.cache.db-shm" does not exist + And the file "photofield.cache.db-wal" does not exist + And the file "photofield.thumbs.db" exists + And the file "photofield.thumbs.db-shm" does not exist + And the file "photofield.thumbs.db-wal" does not exist diff --git a/tests/configs/three-collections.yaml b/tests/configs/three-collections.yaml new file mode 100644 index 0000000..9845372 --- /dev/null +++ b/tests/configs/three-collections.yaml @@ -0,0 +1,12 @@ +collections: + - name: test123 + dirs: + - ./ + + - name: test456 + dirs: + - ./ + + - name: test789 + dirs: + - ./ diff --git a/tests/first-run.feature b/tests/first-run.feature index 93161a3..7ed3ad9 100644 --- a/tests/first-run.feature +++ b/tests/first-run.feature @@ -23,3 +23,23 @@ Feature: First User Experience And waits a second And the user clicks "Retry" Then the page does not show "No collections" + + Scenario: Preconfigured Basic + Given an empty working directory + And the config "configs/three-collections.yaml" + + When the user runs the app + Then the app logs "app running" + And the app logs "config path configuration.yaml" + And the app logs "test123" + And the app logs "test456" + And the app logs "test789" + + When the user opens the home page + Then the page shows "Photos" + + When the user opens the home page + Then the page shows "Photos" + And the page shows "test123" + And the page shows "test456" + And the page shows "test789" \ No newline at end of file diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 7effc0d..f662797 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -42,6 +42,10 @@ class App { await fs.mkdir(join(this.cwd, dir)); } + path(path: string) { + return join(this.cwd, path); + } + async run() { const exe = process.platform === 'win32' ? '.exe' : ''; const command = join(process.cwd(), './photofield' + exe); @@ -105,7 +109,7 @@ class App { } async stop() { - this.proc?.kill(); + this.proc?.kill('SIGTERM'); this.proc = undefined; // Remove the temporary directory // await fs.rmdir(this.cwd, { recursive: true }); diff --git a/tests/steps.ts b/tests/steps.ts index 6f3df94..3ea6ed4 100644 --- a/tests/steps.ts +++ b/tests/steps.ts @@ -1,6 +1,9 @@ import { Page, expect } from '@playwright/test'; import { createBdd } from 'playwright-bdd'; import { test } from './fixtures'; +import fs from 'fs/promises'; +import path from 'path'; + const { Given, When, Then } = createBdd(test); @@ -9,6 +12,12 @@ Given('an empty working directory', async ({ app }) => { console.log("CWD:", app.cwd); }); + +Given('the config {string}', async ({ app }, p: string) => { + const configPath = path.resolve(__dirname, p); + await fs.copyFile(configPath, app.path("configuration.yaml")); +}); + When('the user runs the app', async ({ app }) => { await app.run(); }); @@ -24,6 +33,10 @@ When('the API goes down', async ({ app }) => { await app.stop(); }); +When('the user stops the app', async ({ app }) => { + await app.stop(); +}); + When('the API comes back up', async ({ app }) => { await app.run(); }); @@ -55,7 +68,7 @@ Then('the page shows a progress bar', async ({ page }) => { }); Then('the page shows {string}', async ({ page }, text) => { - await expect(page.getByText(text)).toBeVisible(); + await expect(page.getByText(text).first()).toBeVisible(); }); Then('the page does not show {string}', async ({ page }, text: string) => { @@ -69,7 +82,7 @@ When('the user switches away and back to the page', async ({ page }) => { }); When('the user clicks {string}', async ({ page }, text: string) => { - await page.getByText(text).click(); + await page.getByText(text).first().click(); }); When('the user adds a folder {string}', async ({ app }, name: string) => { @@ -79,4 +92,17 @@ When('the user adds a folder {string}', async ({ app }, name: string) => { When('the user clicks "Retry', async ({ page }) => { await page.getByRole('button', { name: 'Retry' }).click(); }); - \ No newline at end of file + + +Then('the file {string} exists', async ({ app }, filePath: string) => { + await fs.stat(app.path(filePath)); +}); + +Then('the file {string} does not exist', async ({ app }, filePath: string) => { + try { + await fs.stat(app.path(filePath)); + throw new Error("File exists"); + } catch (error) { + expect(error.code).toBe('ENOENT'); + } +}); \ No newline at end of file From b9bc2f423b056cffd627aead786fc29f867cfddd Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 10 Dec 2023 18:27:30 +0100 Subject: [PATCH 08/13] Rescan scene invalidation --- .../tests/connection-error.feature.spec.js | 2 +- .features-gen/tests/first-run.feature.spec.js | 13 ++++++ .features-gen/tests/rescan.feature.spec.js | 25 ++++++++++++ internal/collection/collection.go | 15 +++++++ internal/image/database.go | 40 +++++++++++++++++++ internal/image/source.go | 4 ++ internal/render/scene.go | 16 ++++++++ internal/scene/sceneSource.go | 11 +++-- main.go | 35 ++++++++++++++-- tests/connection-error.feature | 2 +- tests/first-run.feature | 20 ++++++++++ tests/fixtures.ts | 2 +- tests/rescan.feature | 18 +++++++++ tests/steps.ts | 31 +++++++++++++- ui/src/api.js | 10 +++-- ui/src/components/CollectionSettings.vue | 25 +++++++----- 16 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 .features-gen/tests/rescan.feature.spec.js create mode 100644 tests/rescan.feature diff --git a/.features-gen/tests/connection-error.feature.spec.js b/.features-gen/tests/connection-error.feature.spec.js index 5d62b69..6dafd0a 100644 --- a/.features-gen/tests/connection-error.feature.spec.js +++ b/.features-gen/tests/connection-error.feature.spec.js @@ -11,7 +11,7 @@ test.describe("Connection Error Message", () => { test("UI loads, API is up intermittently", async ({ Given, app, And, When, Then, page }) => { await Given("an empty working directory", null, { app }); - await And("a running API", null, { app }); + await And("a running app", null, { app }); await When("the user opens the home page", null, { app }); await Then("the page shows \"Photos\"", null, { page }); await Then("the page does not show \"Connection error\"", null, { page }); diff --git a/.features-gen/tests/first-run.feature.spec.js b/.features-gen/tests/first-run.feature.spec.js index dff16a1..c188b29 100644 --- a/.features-gen/tests/first-run.feature.spec.js +++ b/.features-gen/tests/first-run.feature.spec.js @@ -26,6 +26,19 @@ test.describe("First User Experience", () => { await Then("the page does not show \"No collections\"", null, { page }); }); + test("Add one photo in a dir", async ({ Given, app, When, Then, page, And }) => { + await Given("an empty working directory", null, { app }); + await When("the user runs the app", null, { app }); + await Then("the app logs \"app running\"", null, { app }); + await When("the user opens the home page", null, { app }); + await Then("the page shows \"Photos\"", null, { page }); + await And("the page shows \"No collections\"", null, { page }); + await When("the user adds a folder \"photos\"", null, { app }); + await And("the user adds the following files:", {"dataTable":{"rows":[{"cells":[{"value":"src"},{"value":"dst"}]},{"cells":[{"value":"docs/assets/logo-wide.jpg"},{"value":"photos/a.jpg"}]}]}}, { app }); + await When("the user clicks \"Retry\"", null, { page }); + await Then("the page does not show \"No collections\"", null, { page }); + }); + test("Preconfigured Basic", async ({ Given, app, And, When, Then, page }) => { await Given("an empty working directory", null, { app }); await And("the config \"configs/three-collections.yaml\"", null, { app }); diff --git a/.features-gen/tests/rescan.feature.spec.js b/.features-gen/tests/rescan.feature.spec.js new file mode 100644 index 0000000..a7af3b6 --- /dev/null +++ b/.features-gen/tests/rescan.feature.spec.js @@ -0,0 +1,25 @@ +/** Generated from: tests\rescan.feature */ +import { test } from "..\\..\\tests\\fixtures.ts"; + +test.describe("Rescan", () => { + + test("One photo", async ({ Given, app, And, When, page, Then }) => { + await Given("an empty working directory", null, { app }); + await And("a running app", null, { app }); + await And("the following files:", {"dataTable":{"rows":[{"cells":[{"value":"src"},{"value":"dst"}]},{"cells":[{"value":"docs/assets/logo-wide.jpg"},{"value":"photos/a.jpg"}]}]}}, { app }); + await When("the user opens \"/collections/photos\"", null, { app }); + await And("the user clicks \"photos\"", null, { page }); + await Then("the page shows \"0 files indexed\"", null, { page }); + await When("the user clicks \"Rescan\"", null, { page }); + await Then("the page shows \"1 file indexed\"", null, { page }); + await When("the user clicks \"photos\"", null, { page }); + await Then("the page shows photo \"photos/a.jpg\"", null, { app, page }); + }); + +}); + +// == technical section == + +test.use({ + $test: ({}, use) => use(test), +}); \ No newline at end of file diff --git a/internal/collection/collection.go b/internal/collection/collection.go index f82590f..5834966 100644 --- a/internal/collection/collection.go +++ b/internal/collection/collection.go @@ -23,12 +23,27 @@ type Collection struct { Dirs []string `json:"dirs"` IndexedAt *time.Time `json:"indexed_at,omitempty"` IndexedCount int `json:"indexed_count"` + InvalidatedAt *time.Time `json:"-"` } func (collection *Collection) GenerateId() { collection.Id = slug.Make(collection.Name) } +func (collection *Collection) UpdatedAt() time.Time { + if collection.InvalidatedAt != nil && collection.IndexedAt != nil { + if collection.InvalidatedAt.After(*collection.IndexedAt) { + return *collection.InvalidatedAt + } + return *collection.IndexedAt + } else if collection.InvalidatedAt != nil { + return *collection.InvalidatedAt + } else if collection.IndexedAt != nil { + return *collection.IndexedAt + } + return time.Time{} +} + func (collection *Collection) Expand() []Collection { collections := make([]Collection, 0) for _, collectionDir := range collection.Dirs { diff --git a/internal/image/database.go b/internal/image/database.go index e7e7096..b448883 100644 --- a/internal/image/database.go +++ b/internal/image/database.go @@ -42,11 +42,30 @@ type ListOptions struct { Query *search.Query } +type DirsFunc func(dirs []string) +type stringSet map[string]struct{} + +func (s *stringSet) Add(str string) { + if _, ok := (*s)[str]; ok { + return + } + (*s)[str] = struct{}{} +} + +func (s *stringSet) Slice() []string { + out := make([]string, 0, len(*s)) + for k := range *s { + out = append(out, k) + } + return out +} + type Database struct { path string pool *sqlitex.Pool pending chan *InfoWrite transactionMutex sync.RWMutex + dirUpdateFuncs []DirsFunc } type InfoWriteType int32 @@ -145,6 +164,7 @@ func (source *Database) Close() { if source == nil { return } + source.dirUpdateFuncs = nil source.pool.Close() close(source.pending) } @@ -207,6 +227,10 @@ func (source *Database) migrate(migrations embed.FS) { } } +func (db *Database) HandleDirUpdates(fn DirsFunc) { + db.dirUpdateFuncs = append(db.dirUpdateFuncs, fn) +} + func (source *Database) writePendingInfosSqlite() { conn := source.open() defer conn.Close() @@ -314,6 +338,7 @@ func (source *Database) writePendingInfosSqlite() { inTransaction := false pendingCompactionTags := tagSet{} + pendingUpdatedDirs := make(stringSet) defer func() { source.WaitForCommit() @@ -330,6 +355,7 @@ func (source *Database) writePendingInfosSqlite() { continue } + // Perform pending tag compaction if pendingCompactionTags.Len() > 0 { for id := range pendingCompactionTags { source.CompactTag(id) @@ -343,6 +369,14 @@ func (source *Database) writePendingInfosSqlite() { panic(err) } + // Flush updated dirs + dirs := pendingUpdatedDirs.Slice() + for _, fn := range source.dirUpdateFuncs { + fn(dirs) + } + clear(pendingUpdatedDirs) + + // Optimize if needed if time.Since(lastOptimize).Hours() >= 1 { lastOptimize = time.Now() log.Println("database optimizing") @@ -407,6 +441,8 @@ func (source *Database) writePendingInfosSqlite() { if err != nil { panic(err) } + pendingUpdatedDirs.Add(dir) + case UpdateMeta: dir, file := filepath.Split(imageInfo.Path) _, timezoneOffsetSeconds := imageInfo.DateTime.Zone() @@ -435,6 +471,8 @@ func (source *Database) writePendingInfosSqlite() { if err != nil { panic(err) } + pendingUpdatedDirs.Add(dir) + case UpdateColor: dir, file := filepath.Split(imageInfo.Path) @@ -451,6 +489,7 @@ func (source *Database) writePendingInfosSqlite() { if err != nil { panic(err) } + pendingUpdatedDirs.Add(dir) case UpdateAI: updateAI.BindInt64(1, int64(imageInfo.Id)) @@ -533,6 +572,7 @@ func (source *Database) writePendingInfosSqlite() { if err != nil { panic(err) } + pendingUpdatedDirs.Add(imageInfo.Path) case AddTag: tagName := imageInfo.Path diff --git a/internal/image/source.go b/internal/image/source.go index c4ac9ac..8666a44 100644 --- a/internal/image/source.go +++ b/internal/image/source.go @@ -266,6 +266,10 @@ func NewSource(config Config, migrations embed.FS, migrationsThumbs embed.FS, ge return &source } +func (source *Source) HandleDirUpdates(fn DirsFunc) { + source.database.HandleDirUpdates(fn) +} + func (source *Source) Vacuum() error { return source.database.vacuum() } diff --git a/internal/render/scene.go b/internal/render/scene.go index 558af87..91158a7 100644 --- a/internal/render/scene.go +++ b/internal/render/scene.go @@ -69,6 +69,10 @@ type RegionSource interface { type SceneId = string +type Dependency interface { + UpdatedAt() time.Time +} + type Scene struct { Id SceneId `json:"id"` CreatedAt time.Time `json:"created_at"` @@ -85,6 +89,18 @@ type Scene struct { Solids []Solid `json:"-"` Texts []Text `json:"-"` RegionSource RegionSource `json:"-"` + Stale bool `json:"stale"` + Dependencies []Dependency `json:"-"` +} + +func (scene *Scene) UpdateStaleness() { + for _, dep := range scene.Dependencies { + if dep.UpdatedAt().After(scene.CreatedAt) { + scene.Stale = true + return + } + } + scene.Stale = false } type Scales struct { diff --git a/internal/scene/sceneSource.go b/internal/scene/sceneSource.go index 1b630e7..fd975d3 100644 --- a/internal/scene/sceneSource.go +++ b/internal/scene/sceneSource.go @@ -38,7 +38,7 @@ type storedScene struct { type SceneConfig struct { Render render.Render - Collection collection.Collection + Collection *collection.Collection Layout layout.Layout Scene render.Scene } @@ -154,6 +154,7 @@ func (source *SceneSource) loadScene(config SceneConfig, imageSource *image.Sour Source: imageSource, } } + scene.Dependencies = append(scene.Dependencies, config.Collection) scene.FileCount = len(scene.Photos) scene.Loading = false finished() @@ -195,14 +196,15 @@ func (source *SceneSource) pruneScenes() { } func (source *SceneSource) GetSceneById(id string, imageSource *image.Source) *render.Scene { - value, found := source.sceneCache.Get(id) - if found { - return value + if scene, ok := source.sceneCache.Get(id); ok { + scene.UpdateStaleness() + return scene } stored, loaded := source.scenes.Load(id) if loaded { scene := stored.(storedScene).scene + scene.UpdateStaleness() source.sceneCache.Set(id, scene, getSceneCost(scene)) return scene } @@ -269,6 +271,7 @@ func (source *SceneSource) GetScenesWithConfig(config SceneConfig) []*render.Sce source.scenes.Range(func(_, value interface{}) bool { stored := value.(storedScene) if sceneConfigEqual(stored.config, config) { + stored.scene.UpdateStaleness() scenes = append(scenes, stored.scene) } return true diff --git a/main.go b/main.go index b7f0952..5736512 100644 --- a/main.go +++ b/main.go @@ -381,7 +381,7 @@ func (*Api) PostScenes(w http.ResponseWriter, r *http.Request) { problem(w, r, http.StatusBadRequest, "Collection not found") return } - sceneConfig.Collection = *collection + sceneConfig.Collection = collection sceneConfig.Layout.ViewportWidth = float64(data.ViewportWidth) sceneConfig.Layout.ViewportHeight = float64(data.ViewportHeight) @@ -447,7 +447,7 @@ func (*Api) GetScenes(w http.ResponseWriter, r *http.Request, params openapi.Get problem(w, r, http.StatusBadRequest, "Collection not found") return } - sceneConfig.Collection = *collection + sceneConfig.Collection = collection scenes := sceneSource.GetScenesWithConfig(sceneConfig) sort.Slice(scenes, func(i, j int) bool { @@ -1079,6 +1079,10 @@ func indexCollection(collection *collection.Collection) (task Task, existing boo imageSource.IndexContents(collection.Dirs, collection.IndexLimit, image.Missing{}) globalTasks.Delete(task.Id) close(counter) + + now := time.Now() + collection.IndexedAt = &now + collection.IndexedCount = task.Done }() return } @@ -1091,7 +1095,7 @@ func addExampleScene() { sceneConfig.Layout.ImageHeight = 300 sceneConfig.Layout.Type = layout.Map sceneConfig.Layout.Order = layout.DateAsc - sceneConfig.Collection = *getCollectionById("geo") + sceneConfig.Collection = getCollectionById("geo") sceneSource.Add(sceneConfig, imageSource) } @@ -1178,6 +1182,28 @@ func benchmarkSources(collection *collection.Collection, seed int64, sampleSize bench.BenchmarkSources(seed, sources, samples, count) } +func invalidateDirs(dirs []string) { + now := time.Now() + for i := range collections { + collection := &collections[i] + updated := false + for _, dir := range dirs { + for _, d := range collection.Dirs { + if strings.HasPrefix(dir, d) { + updated = true + break + } + } + if updated { + break + } + } + if updated { + collection.InvalidatedAt = &now + } + } +} + func applyConfig(appConfig *AppConfig) { if globalGeo != nil { err := globalGeo.Close() @@ -1197,7 +1223,7 @@ func applyConfig(appConfig *AppConfig) { } if len(appConfig.Collections) > 0 { - defaultSceneConfig.Collection = appConfig.Collections[0] + defaultSceneConfig.Collection = &appConfig.Collections[0] } collections = appConfig.Collections defaultSceneConfig.Layout = appConfig.Layout @@ -1217,6 +1243,7 @@ func applyConfig(appConfig *AppConfig) { } imageSource = image.NewSource(appConfig.Media, migrations, migrationsThumbs, nil) + imageSource.HandleDirUpdates(invalidateDirs) if tileRequestConfig.Concurrency > 0 { log.Printf("request concurrency %v", tileRequestConfig.Concurrency) tileRequestsOut = make(chan struct{}, 10000) diff --git a/tests/connection-error.feature b/tests/connection-error.feature index 5ffa18a..a659f6c 100644 --- a/tests/connection-error.feature +++ b/tests/connection-error.feature @@ -7,7 +7,7 @@ Feature: Connection Error Message Scenario: UI loads, API is up intermittently Given an empty working directory - And a running API + And a running app When the user opens the home page Then the page shows "Photos" Then the page does not show "Connection error" diff --git a/tests/first-run.feature b/tests/first-run.feature index 7ed3ad9..78be74c 100644 --- a/tests/first-run.feature +++ b/tests/first-run.feature @@ -24,6 +24,26 @@ Feature: First User Experience And the user clicks "Retry" Then the page does not show "No collections" + Scenario: Add one photo in a dir + Given an empty working directory + + When the user runs the app + Then the app logs "app running" + + When the user opens the home page + Then the page shows "Photos" + And the page shows "No collections" + + When the user adds a folder "photos" + And the user adds the following files: + | src | dst | + | docs/assets/logo-wide.jpg | photos/a.jpg | + + When the user clicks "Retry" + Then the page does not show "No collections" + + + Scenario: Preconfigured Basic Given an empty working directory And the config "configs/three-collections.yaml" diff --git a/tests/fixtures.ts b/tests/fixtures.ts index f662797..2d0a754 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -7,7 +7,7 @@ import { BrowserContext, Page } from '@playwright/test'; const LISTEN_REGEX = /local\s+http:\/\/(\S+)/; -class App { +export class App { public cwd: string; public stdout: string; diff --git a/tests/rescan.feature b/tests/rescan.feature new file mode 100644 index 0000000..bb000f2 --- /dev/null +++ b/tests/rescan.feature @@ -0,0 +1,18 @@ +Feature: Rescan + + Scenario: One photo + Given an empty working directory + And a running app + And the following files: + | src | dst | + | docs/assets/logo-wide.jpg | photos/a.jpg | + + When the user opens "/collections/photos" + And the user clicks "photos" + Then the page shows "0 files indexed" + + When the user clicks "Rescan" + Then the page shows "1 file indexed" + + When the user clicks "photos" + Then the page shows photo "photos/a.jpg" diff --git a/tests/steps.ts b/tests/steps.ts index 3ea6ed4..08d8611 100644 --- a/tests/steps.ts +++ b/tests/steps.ts @@ -1,6 +1,7 @@ import { Page, expect } from '@playwright/test'; import { createBdd } from 'playwright-bdd'; -import { test } from './fixtures'; +import { DataTable } from '@cucumber/cucumber'; +import { test, App } from './fixtures'; import fs from 'fs/promises'; import path from 'path'; @@ -18,11 +19,29 @@ Given('the config {string}', async ({ app }, p: string) => { await fs.copyFile(configPath, app.path("configuration.yaml")); }); +async function addFiles(dataTable: DataTable, app: App) { + for (const row of dataTable.rows()) { + const [src, dst] = row; + const srcPath = path.resolve(__dirname, "..", src); + const dstPath = app.path(dst); + await fs.mkdir(path.dirname(dstPath), { recursive: true }); + await fs.copyFile(srcPath, dstPath); + } +} + +Given('the following files:', async ({ app }, dataTable: DataTable) => { + await addFiles(dataTable, app); +}); + +When('the user adds the following files:', async ({ app }, dataTable: DataTable) => { + await addFiles(dataTable, app); +}); + When('the user runs the app', async ({ app }) => { await app.run(); }); -Given('a running API', async ({ app }) => { +Given('a running app', async ({ app }) => { await app.run(); await expect(async () => { expect(app.stderr).toContain("app running"); @@ -63,6 +82,10 @@ When('the user opens the home page', async ({ app }) => { await app.goto("/"); }); +When('the user opens {string}', async ({ app }, path: string) => { + await app.goto(path); +}); + Then('the page shows a progress bar', async ({ page }) => { await expect(page.locator("#content").getByRole('progressbar')).toBeVisible(); }); @@ -105,4 +128,8 @@ Then('the file {string} does not exist', async ({ app }, filePath: string) => { } catch (error) { expect(error.code).toBe('ENOENT'); } +}); + +Then('the page shows photo {string}', async ({ app, page }, path: string) => { + }); \ No newline at end of file diff --git a/ui/src/api.js b/ui/src/api.js index da23812..de32045 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -209,6 +209,7 @@ export function useScene({ const scene = computed(() => { const list = scenes?.value; if (!list || list.length == 0) return null; + if (list[0].stale) return null; return list[0]; }); @@ -220,11 +221,14 @@ export function useScene({ recreateScenesInProgress.value = recreateScenesInProgress.value - 1; } - watch(scenes, async newScene => { - // Create scene if a matching one hasn't been found - if (newScene?.length === 0) { + watch(scenes, async scenes => { + console.log("scene changed", scenes); + if (!scenes || scenes.length === 0) { console.log("scene not found, creating..."); await recreateScene(); + } else if (scenes.length >= 1 && scenes[0].stale) { + console.log("scene stale, recreating..."); + await recreateScene(); } }) diff --git a/ui/src/components/CollectionSettings.vue b/ui/src/components/CollectionSettings.vue index dcf9cdb..bb0f9c1 100644 --- a/ui/src/components/CollectionSettings.vue +++ b/ui/src/components/CollectionSettings.vue @@ -1,6 +1,9 @@