From cb934489f3bd90c245931af88bcb57776a390385 Mon Sep 17 00:00:00 2001 From: agentone666-ship-it <323676787+agentone666-ship-it@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:30:25 -0700 Subject: [PATCH 1/5] feat: add optional browser annotation plugin --- .github/workflows/browser-annotations.yml | 35 + docs/browser-annotations.md | 13 + packages/dsh-page-review/.gitignore | 4 + packages/dsh-page-review/.prettierrc.json | 5 + packages/dsh-page-review/LICENSE | 21 + packages/dsh-page-review/README.md | 58 + packages/dsh-page-review/build.mjs | 24 + packages/dsh-page-review/cordis.patch.yml | 3 + packages/dsh-page-review/install-local.mjs | 64 + packages/dsh-page-review/package-lock.json | 2228 +++++++++++++++++ packages/dsh-page-review/package.json | 74 + packages/dsh-page-review/src/address.mjs | 56 + packages/dsh-page-review/src/browser.mjs | 173 ++ packages/dsh-page-review/src/client.jsx | 2025 +++++++++++++++ .../dsh-page-review/src/composer-compat.mjs | 26 + packages/dsh-page-review/src/composer.mjs | 76 + packages/dsh-page-review/src/document.mjs | 122 + packages/dsh-page-review/src/engine.mjs | 718 ++++++ packages/dsh-page-review/src/frames.mjs | 57 + packages/dsh-page-review/src/host.mjs | 188 ++ packages/dsh-page-review/src/inspect.mjs | 104 + packages/dsh-page-review/src/interaction.mjs | 120 + packages/dsh-page-review/src/links.mjs | 64 + packages/dsh-page-review/src/locale.mjs | 250 ++ packages/dsh-page-review/src/message.jsx | 179 ++ packages/dsh-page-review/src/packet.mjs | 101 + packages/dsh-page-review/src/placement.mjs | 79 + packages/dsh-page-review/src/style.css | 1284 ++++++++++ packages/dsh-page-review/src/submission.mjs | 53 + packages/dsh-page-review/src/tabs.mjs | 146 ++ packages/dsh-page-review/src/transcript.mjs | 96 + .../dsh-page-review/tests/address.test.mjs | 56 + .../dsh-page-review/tests/browser.test.mjs | 309 +++ .../dsh-page-review/tests/compat.test.mjs | 67 + .../dsh-page-review/tests/composer.test.mjs | 134 + .../dsh-page-review/tests/document.test.mjs | 187 ++ .../dsh-page-review/tests/frames.test.mjs | 109 + .../dsh-page-review/tests/install.test.mjs | 51 + .../tests/interaction.test.mjs | 232 ++ packages/dsh-page-review/tests/links.test.mjs | 175 ++ .../dsh-page-review/tests/placement.test.mjs | 80 + .../dsh-page-review/tests/review.test.mjs | 250 ++ .../dsh-page-review/tests/submission.test.mjs | 161 ++ packages/dsh-page-review/tests/tabs.test.mjs | 213 ++ .../dsh-page-review/tests/transcript.test.mjs | 139 + ...sh-client-ui-conversation+0.2.1-rc.2.patch | 8 + 46 files changed, 10617 insertions(+) create mode 100644 .github/workflows/browser-annotations.yml create mode 100644 docs/browser-annotations.md create mode 100644 packages/dsh-page-review/.gitignore create mode 100644 packages/dsh-page-review/.prettierrc.json create mode 100644 packages/dsh-page-review/LICENSE create mode 100644 packages/dsh-page-review/README.md create mode 100644 packages/dsh-page-review/build.mjs create mode 100644 packages/dsh-page-review/cordis.patch.yml create mode 100644 packages/dsh-page-review/install-local.mjs create mode 100644 packages/dsh-page-review/package-lock.json create mode 100644 packages/dsh-page-review/package.json create mode 100644 packages/dsh-page-review/src/address.mjs create mode 100644 packages/dsh-page-review/src/browser.mjs create mode 100644 packages/dsh-page-review/src/client.jsx create mode 100644 packages/dsh-page-review/src/composer-compat.mjs create mode 100644 packages/dsh-page-review/src/composer.mjs create mode 100644 packages/dsh-page-review/src/document.mjs create mode 100644 packages/dsh-page-review/src/engine.mjs create mode 100644 packages/dsh-page-review/src/frames.mjs create mode 100644 packages/dsh-page-review/src/host.mjs create mode 100644 packages/dsh-page-review/src/inspect.mjs create mode 100644 packages/dsh-page-review/src/interaction.mjs create mode 100644 packages/dsh-page-review/src/links.mjs create mode 100644 packages/dsh-page-review/src/locale.mjs create mode 100644 packages/dsh-page-review/src/message.jsx create mode 100644 packages/dsh-page-review/src/packet.mjs create mode 100644 packages/dsh-page-review/src/placement.mjs create mode 100644 packages/dsh-page-review/src/style.css create mode 100644 packages/dsh-page-review/src/submission.mjs create mode 100644 packages/dsh-page-review/src/tabs.mjs create mode 100644 packages/dsh-page-review/src/transcript.mjs create mode 100644 packages/dsh-page-review/tests/address.test.mjs create mode 100644 packages/dsh-page-review/tests/browser.test.mjs create mode 100644 packages/dsh-page-review/tests/compat.test.mjs create mode 100644 packages/dsh-page-review/tests/composer.test.mjs create mode 100644 packages/dsh-page-review/tests/document.test.mjs create mode 100644 packages/dsh-page-review/tests/frames.test.mjs create mode 100644 packages/dsh-page-review/tests/install.test.mjs create mode 100644 packages/dsh-page-review/tests/interaction.test.mjs create mode 100644 packages/dsh-page-review/tests/links.test.mjs create mode 100644 packages/dsh-page-review/tests/placement.test.mjs create mode 100644 packages/dsh-page-review/tests/review.test.mjs create mode 100644 packages/dsh-page-review/tests/submission.test.mjs create mode 100644 packages/dsh-page-review/tests/tabs.test.mjs create mode 100644 packages/dsh-page-review/tests/transcript.test.mjs diff --git a/.github/workflows/browser-annotations.yml b/.github/workflows/browser-annotations.yml new file mode 100644 index 000000000..d3f8db762 --- /dev/null +++ b/.github/workflows/browser-annotations.yml @@ -0,0 +1,35 @@ +name: Optional browser annotations +on: + pull_request: + paths: + - packages/dsh-page-review/** + - .github/workflows/browser-annotations.yml + push: + branches: [main] + paths: + - packages/dsh-page-review/** + - .github/workflows/browser-annotations.yml +permissions: + contents: read +jobs: + plugin: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: packages/dsh-page-review + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: packages/dsh-page-review/package-lock.json + - run: npm ci + - run: npm run build + - run: npm test + - run: npm pack --dry-run + - name: Generated output is not tracked + run: git diff --exit-code diff --git a/docs/browser-annotations.md b/docs/browser-annotations.md new file mode 100644 index 000000000..aa760ba36 --- /dev/null +++ b/docs/browser-annotations.md @@ -0,0 +1,13 @@ +# Optional browser annotation contribution + +`packages/dsh-page-review` contains the independently built browser annotation plugin. It is not in the default Desktop dependency/profile/bundle set. Its package README documents installation, lifecycle, privacy, limits and the validation boundary. + +## Composer marker contract + +Target: `@deepseek-ai/dsh-client-ui-conversation@0.2.1-rc.2`, `InputBar` primary button. The patch adds `data-dsh-composer-primary`, with value `send` or `stop` from the existing `primaryStops` decision. + +This is a DOM compatibility marker, not a new slot. Slot kinds, scopes, props, ordering, mounting and native handlers do not change. With no plugin it has no visual or event effect. The optional annotation adapter uses it to avoid matching translated labels or hashed CSS, preserves the Stop action, and releases its observer with the accessory. Other composer contributions retain their native mounting and positions. Annotation CSS is scoped by its own accessory marker. + +The model packet/attachment transaction remains plugin-owned. The host currently has no public contribution for annotation-only content in the ordinary submit transaction; the marker makes the narrow compatibility adapter inspectable while that native extension is designed. The removal condition is a public native submission-extension API covering extra content, normal send/Enter, permission/phase guards, attachments and retry admission. + +Validation includes annotation/image transaction regressions, observer teardown, marker absence, Stop/Send transitions, paired locale catalogs, clean package generation and patch replay. Desktop 0.12.0 validation of the incoming prototype does not prove current-main UI compatibility; keep this contribution opt-in pending real current-Desktop smoke testing and cross-platform browser checks. diff --git a/packages/dsh-page-review/.gitignore b/packages/dsh-page-review/.gitignore new file mode 100644 index 000000000..49301023d --- /dev/null +++ b/packages/dsh-page-review/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +lib/ +backups/ +*.tgz diff --git a/packages/dsh-page-review/.prettierrc.json b/packages/dsh-page-review/.prettierrc.json new file mode 100644 index 000000000..b18aca9c7 --- /dev/null +++ b/packages/dsh-page-review/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2 +} diff --git a/packages/dsh-page-review/LICENSE b/packages/dsh-page-review/LICENSE new file mode 100644 index 000000000..f37235ec4 --- /dev/null +++ b/packages/dsh-page-review/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DataElement + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/dsh-page-review/README.md b/packages/dsh-page-review/README.md new file mode 100644 index 000000000..cba665489 --- /dev/null +++ b/packages/dsh-page-review/README.md @@ -0,0 +1,58 @@ +# Browser annotations (optional plugin) + +An opt-in browser beside a DSH conversation: search or enter a URL, browse in native right-sidebar tabs, select an element or drag a region, and write an anchored comment. Hold Space to interact with the page; release it to continue annotating. + +Saved comments appear above the composer as an annotation chip. Its non-modal popover shows the selection screenshots and comments, with element details collapsed. Submission combines the composer draft, existing attachments, JPEG image blocks and matching element/comment metadata in one admission. A readable annotation card projects sent messages without changing the durable model payload. Missing screenshots block sending. Retries reuse the request ID, and concurrent edits remain pending. + +This package is **not installed or enabled by default**, and does not add a second Agent runtime. Session chat/mail plugins, local profiles, screenshots and user records are outside this contribution. + +## Build and test + +From the repository root: + +```sh +npm --prefix packages/dsh-page-review ci +npm --prefix packages/dsh-page-review run build +npm --prefix packages/dsh-page-review test +npm pack ./packages/dsh-page-review --dry-run +``` + +The package owns its pinned build/test dependencies and lockfile. `prepack` builds the CommonJS client registration wrapper. `lib/`, installed dependencies, local backups and archives are generated and ignored; only `src/` is maintained. The dedicated CI workflow tests the optional distribution separately from the desktop application's default dependency closure. + +## Try in a development profile + +Use a separate profile with the composer marker provided by this PR. After building: + +```sh +DSH_REVIEW_PROFILE=/path/to/test/harness/profiles/web \ + node packages/dsh-page-review/install-local.mjs +``` + +Restart the development Harness, then choose **+ → Browser** in the right sidebar or press Cmd/Ctrl+Shift+B. The generic new-tab chooser remains available. Native Markdown links use the conventional `browser` tab entry; the plugin does not install a document-wide link interceptor. + +To unregister from that same test profile while keeping annotation data: + +```sh +DSH_REVIEW_PROFILE=/path/to/test/harness/profiles/web \ + node packages/dsh-page-review/install-local.mjs --remove +``` + +The installer changes only this package's `link:` dependency, bundle selection and own symlink, backs up the profile manifest locally, and refuses an occupied link. Do not commit those backups. + +## Compatibility seam and review boundary + +The incoming implementation was exercised in Desktop 0.12.0 / Harness 0.2.0-rc.2. This contribution targets the repository's pinned Harness 0.2.1-rc.2 and requires a fresh Desktop UI smoke test before enabling it in a release. + +The only host patch addition is `data-dsh-composer-primary="send" | "stop"` on the existing primary button. It changes no slot, handler, permission or default rendering. `src/composer-compat.mjs` observes that marker and editor availability, rather than translated labels or generated class names. With pending annotations, the plugin presents its submit control at the send seat; while Stop is primary, its action stays visible and annotation submission is inline. Without the marker, submission is disabled. Native Enter interception is limited to an editable composer with pending annotations, plain input phase and available submission. This adapter is intentionally documented coupling, pending a native submission-extension API; remove it and the marker patch when that API exists. + +The native sidebar tab/body/title, conversation accessory and chat-node slots are registered with Cordis effects. Chinese and English UI copy uses the Harness locale service, and CSS uses host semantic tokens. A disabled/uninstalled plugin leaves the native composer and ordinary message rendering intact. + +**Document-preview UI is deliberately not registered here:** current main does not expose the legacy file-preview action/DOM contract used by the local PDF/Word/Markdown prototype. Its packet/location validation helpers remain tested, but this PR claims browser annotations only. File preview integration needs its own reviewed provider seam. + +## Browser, data and limitations + +Uses `puppeteer-core` and a locally installed Chrome/Edge/Chromium, without downloading another browser. The headless browser has a dedicated profile under `$DSH_HOME/page-review/browser-profile`; it does not borrow the system browser's logged-in profile. The screenshot-frame surface supports clicking, scrolling, text/keyboard input, search, back/forward and popup tabs. It has lower frame rate than a native browser; upload/download management, video and system authentication dialogs are outside the current scope. + +Annotation metadata and JPEG captures persist under `$DSH_HOME/page-review/sessions//`. The model receives real image blocks plus URL, selection coordinates, selector/DOM/style evidence and the user's comment. Form values and sensitive attributes are stripped by the inspector, but a screenshot can still contain private visible page content: inspect the annotation popover before sending. All routes require loopback, same-origin requests, reject forwarded/cross-site requests and bound request bodies. Node/browser process access is privileged host code, not a renderer sandbox escape. + +Limits: 12 tabs per session, 40 pending annotations, and at most 12 images / 12 MB per submission. A successful admission means DSH received the packet, not that the chosen model successfully processed its images. Windows/Linux browser and full current-Desktop UI compatibility remain release-validation items. diff --git a/packages/dsh-page-review/build.mjs b/packages/dsh-page-review/build.mjs new file mode 100644 index 000000000..58000f4a5 --- /dev/null +++ b/packages/dsh-page-review/build.mjs @@ -0,0 +1,24 @@ +import { build } from 'esbuild' +import { mkdir, writeFile } from 'node:fs/promises' + +const root = new URL('./', import.meta.url) +await mkdir(new URL('lib/', root), { recursive: true }) +await writeFile( + new URL('lib/index.js', root), + "export * from '../src/host.mjs'\n", +) +const result = await build({ + entryPoints: [new URL('src/client.jsx', root).pathname], + bundle: true, + write: false, + platform: 'browser', + format: 'cjs', + target: 'es2022', + external: ['react', 'react-dom'], + loader: { '.css': 'text' }, + legalComments: 'inline', +}) +await writeFile( + new URL('lib/client.js', root), + `window.__ModuleLoader__.load({id:'dsh-page-review',factory:(require)=>{const module={exports:{}};const exports=module.exports;\n${result.outputFiles[0].text}\nreturn module.exports;}});\n`, +) diff --git a/packages/dsh-page-review/cordis.patch.yml b/packages/dsh-page-review/cordis.patch.yml new file mode 100644 index 000000000..561e077dc --- /dev/null +++ b/packages/dsh-page-review/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: dsh-page-review + name: dsh-page-review diff --git a/packages/dsh-page-review/install-local.mjs b/packages/dsh-page-review/install-local.mjs new file mode 100644 index 000000000..df61670c0 --- /dev/null +++ b/packages/dsh-page-review/install-local.mjs @@ -0,0 +1,64 @@ +import { + readFile, + writeFile, + mkdir, + symlink, + lstat, + realpath, + unlink, + copyFile, + rename, +} from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = dirname(fileURLToPath(import.meta.url)) +const profile = process.env.DSH_REVIEW_PROFILE +if (!profile) + throw new Error( + 'Set DSH_REVIEW_PROFILE to an explicit development profile before installing.', + ) +const name = 'dsh-page-review', + manifest = join(profile, 'package.json'), + link = join(profile, 'node_modules', name) +const pkg = JSON.parse(await readFile(manifest, 'utf8')) +const removing = process.argv.includes('--remove') +let exists = false +try { + await lstat(link) + exists = true + if ((await realpath(link)) !== root) + throw Error('插件路径已被其他安装占用:' + link) +} catch (e) { + if (e.code !== 'ENOENT') throw e +} +await mkdir(join(root, 'backups'), { recursive: true }) +await copyFile( + manifest, + join(root, 'backups', 'profile-' + Date.now() + '.json'), +) +if (removing) { + if (exists) await unlink(link) + if (pkg.dependencies?.[name] === 'link:' + root) delete pkg.dependencies[name] + pkg.dsh.profile.bundles = pkg.dsh.profile.bundles.filter((x) => x !== name) +} else { + await readFile(join(root, 'lib/client.js')) + pkg.dependencies ||= {} + if (pkg.dependencies[name] && pkg.dependencies[name] !== 'link:' + root) + throw Error('已有其他版本,请先检查') + if (!exists) await symlink(root, link, 'dir') + pkg.dependencies[name] = 'link:' + root + if (!pkg.dsh.profile.bundles.includes(name)) + pkg.dsh.profile.bundles.push(name) +} +const tmp = manifest + '.review.tmp' +await writeFile(tmp, JSON.stringify(pkg, null, 2) + '\n') +await rename(tmp, manifest) +console.log( + JSON.stringify({ + installed: !removing, + profile, + source: root, + restartRequired: true, + }), +) diff --git a/packages/dsh-page-review/package-lock.json b/packages/dsh-page-review/package-lock.json new file mode 100644 index 000000000..a1743a49d --- /dev/null +++ b/packages/dsh-page-review/package-lock.json @@ -0,0 +1,2228 @@ +{ + "name": "dsh-page-review", + "version": "0.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dsh-page-review", + "version": "0.3.0", + "license": "MIT", + "dependencies": { + "puppeteer-core": "23.11.1" + }, + "devDependencies": { + "esbuild": "0.25.12", + "jsdom": "26.0.0", + "prettier": "3.6.2" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz", + "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.0", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz", + "integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.5.tgz", + "integrity": "sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chromium-bidi": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz", + "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "3.0.1", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1367902", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", + "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", + "license": "BSD-3-Clause" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "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, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ip-address": { + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.0.0.tgz", + "integrity": "sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.1", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer-core": { + "version": "23.11.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.11.1.tgz", + "integrity": "sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.6.1", + "chromium-bidi": "0.11.0", + "debug": "^4.4.0", + "devtools-protocol": "0.0.1367902", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "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==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz", + "integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.9.0", + "bare-fs": "^4.8.2", + "fast-fifo": "^1.3.2", + "streamx": "^2.28.1" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typed-query-selector": { + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz", + "integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==", + "license": "MIT" + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT", + "optional": true + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/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==", + "license": "MIT", + "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/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/dsh-page-review/package.json b/packages/dsh-page-review/package.json new file mode 100644 index 000000000..22656a657 --- /dev/null +++ b/packages/dsh-page-review/package.json @@ -0,0 +1,74 @@ +{ + "name": "dsh-page-review", + "version": "0.3.0", + "private": true, + "description": "Optional browser annotations with screenshots, element context and chat handoff.", + "type": "module", + "main": "lib/index.js", + "exports": { + ".": "./lib/index.js", + "./client": "./lib/client.js", + "./package.json": "./package.json", + "./cordis.patch.yml": "./cordis.patch.yml" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-ui-slots", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-resources", + "@deepseek-ai/dsh-client-locale" + ] + } + }, + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "node build.mjs", + "test": "node --test tests/*.test.mjs", + "prepack": "npm run build", + "format": "prettier --write src tests build.mjs install-local.mjs" + }, + "dependencies": { + "puppeteer-core": "23.11.1" + }, + "license": "MIT", + "files": [ + "lib", + "src", + "cordis.patch.yml", + "README.md", + "LICENSE" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/dataelement/dsh-desktop.git", + "directory": "packages/dsh-page-review" + }, + "devDependencies": { + "esbuild": "0.25.12", + "jsdom": "26.0.0", + "prettier": "3.6.2" + }, + "peerDependencies": { + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } +} diff --git a/packages/dsh-page-review/src/address.mjs b/packages/dsh-page-review/src/address.mjs new file mode 100644 index 000000000..8ca1b802f --- /dev/null +++ b/packages/dsh-page-review/src/address.mjs @@ -0,0 +1,56 @@ +export const SEARCH_ENGINES = { + bing: { name: '必应', url: 'https://www.bing.com/search', param: 'q' }, + baidu: { name: '百度', url: 'https://www.baidu.com/s', param: 'wd' }, + google: { name: 'Google', url: 'https://www.google.com/search', param: 'q' }, +} + +export function resolveAddress(value, hostOrigin = '', searchEngine = 'bing') { + const input = String(value || '').trim() + if (!input) throw Error('请输入网址或搜索内容') + if (input === 'about:blank') return input + // Explicit schemes are addresses, never search terms. Local host:port is not a scheme. + const explicit = + /^[a-z][a-z0-9+.-]*:\/\//i.test(input) || + (/^[a-z][a-z0-9+-]*:/i.test(input) && !/^localhost:\d/i.test(input)) + if (explicit && !/^https?:\/\//i.test(input)) + throw Error('只支持 HTTP(S) 网页') + if (/^[^\s/@]+:[^\s/@]+@/.test(input)) throw Error('网址不能包含用户名或密码') + const local = + /^(?:localhost|(?:\d{1,3}\.){3}\d{1,3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test( + input, + ) + const domain = + /^(?:[\p{L}\p{N}_-]+\.)+[\p{L}\p{N}-]{2,}(?::\d+)?(?:[/?#]|$)/u.test(input) + const port = /^\d{2,5}$/.test(input) && Number(input) <= 65535 + if (explicit || local || domain || port) { + let u + try { + u = new URL( + explicit + ? input + : port + ? 'http://127.0.0.1:' + input + : (local ? 'http://' : 'https://') + input, + ) + } catch { + throw Error('网址格式不正确,请检查后重试') + } + if (u.username || u.password) throw Error('网址不能包含用户名或密码') + if (hostOrigin) { + const host = new URL(hostOrigin), + loopback = (name) => ['localhost', '127.0.0.1', '[::1]'].includes(name) + if ( + u.origin === host.origin || + (loopback(u.hostname) && + loopback(host.hostname) && + u.port === host.port) + ) + throw Error('请在浏览器中打开其他网站,避免嵌套 DSH 自身') + } + return u.href + } + const engine = SEARCH_ENGINES[searchEngine] || SEARCH_ENGINES.bing + const url = new URL(engine.url) + url.searchParams.set(engine.param, input) + return url.href +} diff --git a/packages/dsh-page-review/src/browser.mjs b/packages/dsh-page-review/src/browser.mjs new file mode 100644 index 000000000..2a5443769 --- /dev/null +++ b/packages/dsh-page-review/src/browser.mjs @@ -0,0 +1,173 @@ +import puppeteer from 'puppeteer-core' +import { readFile, readlink, unlink, mkdir } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { hostname } from 'node:os' + +// Survives module reloads. Every engine holds a lease on the same profile owner. +const key = Symbol.for('dsh-page-review.browser-leases.v1') +const owners = (globalThis[key] ??= new Map()) +const alive = (pid) => { + try { + process.kill(pid, 0) + return true + } catch (e) { + return e.code !== 'ESRCH' + } +} +export async function clearStaleBrowserLock( + profile, + { isAlive = alive, host = hostname() } = {}, +) { + const file = join(profile, 'SingletonLock') + let lock + try { + lock = await readlink(file) + } catch (e) { + if (e.code === 'ENOENT') return + throw e + } + const match = lock.match(/^(.*)-(\d+)$/) + if (!match || match[1] !== host || isAlive(Number(match[2]))) + throw Error( + '浏览器配置正在使用,但暂时无法重新连接。请重启 DSH 后重试;网页和批注已保留。', + ) + // Never unlink an active owner's lock or follow the socket/cookie symlinks. + if ((await readlink(file)) !== lock || isAlive(Number(match[2]))) + throw Error('浏览器正在重启,请稍后重试') + for (const name of ['SingletonSocket', 'SingletonCookie', 'SingletonLock']) + await unlink(join(profile, name)).catch((e) => { + if (e.code !== 'ENOENT') throw e + }) +} +export async function reconnectBrowser(profile, { driver = puppeteer } = {}) { + let browser + try { + const [port, path] = String( + await readFile(join(profile, 'DevToolsActivePort'), 'utf8'), + ) + .trim() + .split(/\r?\n/) + if ( + !/^\d+$/.test(port) || + Number(port) < 1 || + Number(port) > 65535 || + !/^\/devtools\/browser\/[a-zA-Z0-9-]+$/.test(path) + ) + return null + // Probe with a bounded request before attempting the WebSocket handshake. + const response = await fetch(`http://127.0.0.1:${port}/json/version`, { + signal: AbortSignal.timeout(1500), + }) + const info = await response.json() + if (new URL(info.webSocketDebuggerUrl).pathname !== path) return null + browser = await driver.connect({ + browserWSEndpoint: `ws://127.0.0.1:${port}${path}`, + defaultViewport: null, + protocolTimeout: 5000, + }) + const client = await browser.target().createCDPSession() + try { + const { arguments: args } = await client.send( + 'Browser.getBrowserCommandLine', + ) + if (!args.includes(`--user-data-dir=${profile}`)) + throw Error('浏览器配置不匹配') + } finally { + await client.detach() + } + return browser + } catch { + await browser?.disconnect().catch(() => {}) + return null + } +} +export async function openProfileBrowser(profile, options = {}, deps = {}) { + const reconnect = deps.reconnect || reconnectBrowser, + clear = deps.clear || clearStaleBrowserLock, + driver = deps.driver || puppeteer + await mkdir(profile, { recursive: true }) + const existing = await reconnect(profile) + if (existing) return existing + await clear(profile) + try { + return await driver.launch({ ...options, userDataDir: profile }) + } catch (error) { + // Another host may have completed startup between probing and launching. + const recovered = await reconnect(profile) + if (recovered) return recovered + if (/ProcessSingleton|SingletonLock/.test(error.message)) + throw Error( + '浏览器配置正在使用,暂时无法连接。请重启 DSH 后重试;网页和批注已保留。', + ) + throw error + } +} +export async function acquireProfileBrowser( + profile, + options = {}, + open = openProfileBrowser, +) { + profile = resolve(profile) + for (;;) { + let entry = owners.get(profile) + if (entry?.closing) { + await entry.closing + continue + } + if (!entry) { + entry = { users: new Set(), browser: null, promise: null, closing: null } + owners.set(profile, entry) + entry.promise = Promise.resolve() + .then(() => open(profile, options)) + .then((browser) => { + entry.browser = browser + browser.on('disconnected', () => { + if (owners.get(profile) === entry && !entry.closing) + owners.delete(profile) + }) + return browser + }) + .catch((error) => { + if (owners.get(profile) === entry) owners.delete(profile) + throw error + }) + } + const token = {} + entry.users.add(token) + let browser + try { + browser = await entry.promise + } catch (error) { + entry.users.delete(token) + throw error + } + if (!browser.connected) { + entry.users.delete(token) + if (owners.get(profile) === entry) owners.delete(profile) + throw Error('浏览器连接已中断,请重试') + } + let released = false + return { + browser, + release: async () => { + if (released) return + released = true + entry.users.delete(token) + if (entry.users.size) return + // A disconnected launcher may still hold an OS process handle. Closing + // that stale handle would kill the process a newer lease just reattached. + if (!browser.connected) { + if (owners.get(profile) === entry) owners.delete(profile) + return + } + entry.closing = Promise.resolve() + .then(() => browser.close()) + .catch(() => browser.disconnect()) + .finally(() => { + if (owners.get(profile) === entry) owners.delete(profile) + }) + await entry.closing + }, + } + } +} diff --git a/packages/dsh-page-review/src/client.jsx b/packages/dsh-page-review/src/client.jsx new file mode 100644 index 000000000..90009c7e7 --- /dev/null +++ b/packages/dsh-page-review/src/client.jsx @@ -0,0 +1,2025 @@ +import { observeComposer } from './composer-compat.mjs' +import { t, configureLocale } from './locale.mjs' +import React, { useState, useEffect, useRef, useSyncExternalStore } from 'react' +import css from './style.css' +import { deliverPacket } from './packet.mjs' +import { SEARCH_ENGINES } from './address.mjs' +import { holdToBrowse, presentTemporaryBrowsing } from './interaction.mjs' +import { createFramePump } from './frames.mjs' +import { + captureComposer, + withComposerText, + composerAttachments, + retireComposer, +} from './composer.mjs' +import { createDirectSender } from './submission.mjs' +import { annotationPlacement } from './placement.mjs' +import { browserPage, createBrowserTabs } from './tabs.mjs' +import { annotationMessageRenderer } from './message.jsx' +import { webLink, createLinkOpener } from './links.mjs' +const BASE = '/api/page-review', + KIND = 'page-review', + TAB = 'dsh-page-review-tab' +export const inject = [ + 'sessions', + 'slots', + 'sidebarRight', + 'sidebarRightTabs', + 'conversation', + 'resources', + 'locale', +] +const stores = new Map() +let sessionsService, conversationService, sidebarService, openBrowserLink +const tabBridges = new Map() +function inputShellFor(sid) { + const scope = + sessionsService.binding(sid)?.ctx || sessionsService.scope?.(sid) + if (!scope) throw Error(t('the.composer.is.unavailable.reopen.this.chat')) + return conversationService.input.for(scope) +} +function modelFor(sid) { + if (!stores.has(sid)) { + let state = { + tabs: [], + active: null, + notes: [], + history: [], + visits: [], + error: '', + loaded: false, + editRequest: null, + sending: false, + sendError: '', + } + const listeners = new Set() + stores.set(sid, { + get: () => state, + set: (patch) => { + state = { ...state, ...(tabBridges.get(sid)?.filter(patch) || patch) } + for (const f of listeners) f() + }, + subscribe: (f) => { + listeners.add(f) + return () => listeners.delete(f) + }, + }) + } + return stores.get(sid) +} +const imageURL = (sid, id) => + `${BASE}/capture?session=${encodeURIComponent(sid)}&id=${encodeURIComponent(id)}` +async function api(route, args, method = 'POST') { + const res = await fetch(BASE + route, { + method, + headers: { 'content-type': 'application/json' }, + body: method === 'POST' ? JSON.stringify(args) : undefined, + signal: AbortSignal.timeout(35000), + }) + const result = await res.json() + if (!res.ok || !result.ok) throw Error(result.error || t('request.failed')) + return result.value +} +const get = (route, params) => + api(route + '?' + new URLSearchParams(params), null, 'GET') + +function senderFor(sid) { + const model = modelFor(sid) + if (!model.sender) + model.sender = createDirectSender({ + prepare: async () => { + const shell = inputShellFor(sid), + capture = captureComposer(shell.snapshot) + const prepared = await api('/prepare', { + session: sid, + ids: model.get().notes.map((n) => n.id), + }) + const extras = await composerAttachments( + conversationService, + capture, + sid, + prepared.images.length, + ) + return { + ...withComposerText(prepared, capture), + composer: capture, + composerShell: shell, + nativeExtras: extras, + } + }, + deliver: (packet, attempt) => { + const binding = sessionsService.binding(sid), + scope = binding?.ctx || sessionsService.scope?.(sid) + return deliverPacket( + binding?.session || (scope && sessionsService.sessionOf(scope)), + packet, + attempt, + packet.nativeExtras, + ) + }, + retire: (packet) => + retireComposer( + packet.composer, + packet.composerShell.snapshot, + packet.composerShell.actions, + ), + ack: (packet) => + api('/ack', { session: sid, deliveryId: packet.deliveryId }), + onStart: () => model.set({ sending: true, sendError: '' }), + onSuccess: (state) => + model.set({ ...state, sendError: '', sentAt: Date.now() }), + onError: (error) => + model.set({ + sendError: + error.message || + t('sending.failed.annotations.and.draft.were.kept.retry'), + }), + onEnd: () => model.set({ sending: false }), + }) + return model.sender +} +function sendComposer(sid) { + void senderFor(sid) + .send() + .catch(() => {}) +} + +function Icon({ name, ...props }) { + const paths = { + external: + 'M14 3h7v7M21 3 10 14M10 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5', + comment: + 'M5 4h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-6l-5 4v-4H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2ZM8 9h8M8 13h5', + back: 'm14 5-7 7 7 7', + forward: 'm10 5 7 7-7 7', + refresh: 'M20 7v5h-5M20 12a8 8 0 1 0-2 6', + plus: 'M12 5v14M5 12h14', + close: 'm6 6 12 12M6 18 18 6', + mark: 'm3 3 5 17 3-7 7-3Z', + globe: + 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM3 12h18M12 3c5 5 5 13 0 18-5-5-5-13 0-18Z', + list: 'M9 6h12M9 12h12M9 18h12M3 6h1M3 12h1M3 18h1', + send: 'M12 20V4m-6 6 6-6 6 6', + edit: 'm15 4 5 5-10 10-6 1 1-6Z', + trash: 'M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6M14 11v6', + check: 'm5 12 4 4L19 6', + image: 'M3 4h18v16H3ZM3 16l5-5 4 4 3-3 6 6M16 8h.01', + history: 'M3 12a9 9 0 1 0 3-6M3 4v5h5M12 7v5l3 2', + expand: 'M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5', + } + return ( + + ) +} +function Tool({ title, icon, active, ...props }) { + return ( + + ) +} +function localDraft(sid, value) { + try { + if (value) + localStorage.setItem('page-review:draft:' + sid, JSON.stringify(value)) + else localStorage.removeItem('page-review:draft:' + sid) + } catch {} +} +function readDraft(sid) { + try { + return JSON.parse( + localStorage.getItem('page-review:draft:' + sid) || 'null', + ) + } catch { + return null + } +} +function savedSearchEngine() { + try { + const key = localStorage.getItem('page-review:search-engine') + return SEARCH_ENGINES[key] ? key : 'bing' + } catch { + return 'bing' + } +} +function draftKey(sid, pageId) { + return sid + ':' + pageId +} +function bridgeFor(sid) { + if (!tabBridges.has(sid)) { + const model = modelFor(sid) + tabBridges.set( + sid, + createBrowserTabs({ + sessionId: sid, + kind: KIND, + native: sidebarService, + model, + create: () => + api('/browser', { session: sid, kind: 'create', url: '' }), + close: (tabId) => + api('/browser', { session: sid, kind: 'close', tabId }), + hasDraft: (id) => Boolean(readDraft(draftKey(sid, id))), + onError: (error) => model.set({ error: error.message }), + }), + ) + } + return tabBridges.get(sid) +} +function BrowserTab(props) { + const info = props.useTabInfo(), + sid = props.sessionId, + pageId = browserPage(info.tab.navigation.address, sid) + const model = modelFor(sid), + state = useSyncExternalStore(model.subscribe, model.get, model.get) + useEffect(() => { + let alive = true + if (!model.get().loaded) + get('/state', { session: sid }) + .then((value) => { + if (alive) model.set({ ...value, loaded: true, error: '' }) + }) + .catch((e) => model.set({ error: e.message, loaded: true })) + return () => { + alive = false + } + }, [sid]) + useEffect(() => { + if (!state.loaded) return + const bridge = bridgeFor(sid) + if (pageId) { + bridge.adopt(info) + return + } + void bridge.launch(info) + }, [sid, pageId, state.loaded, info.tab.id]) + return pageId ? ( + + ) : ( +
+
{state.error || t('opening.browser')}
+
+ ) +} +function BrowserTabTitle({ sessionId, useTabInfo }) { + const info = useTabInfo(), + model = modelFor(sessionId), + state = useSyncExternalStore(model.subscribe, model.get, model.get) + const id = browserPage(info.tab.navigation.address, sessionId), + page = state.tabs.find((t) => t.id === id) + return ( + + + {page?.title || t('new.tab')} + + ) +} +function BrowserLinkEntry({ sessionId, useTabInfo }) { + const info = useTabInfo(), + [error, setError] = useState(''), + started = useRef(null) + useEffect(() => { + const revision = info.tab.navigation.revision + if (started.current === revision) return + started.current = revision + const url = info.tab.navigation.params?.url + if (!webLink(url)) { + info.tab.actions.openTab(KIND, { replaceTab: true }) + return + } + void openBrowserLink(sessionId, url, { replaceTab: info.tab.id }).catch( + (e) => setError(e.message), + ) + }, [sessionId, info.tab.navigation.revision]) + return ( +
+
{error || t('opening.page')}
+
+ ) +} +function ReviewPanel({ sessionId, useTabInfo, pageId }) { + const tabInfo = useTabInfo?.(), + visible = tabInfo?.tab.visible ?? true + const sid = sessionId, + model = modelFor(sid), + shared = useSyncExternalStore(model.subscribe, model.get, model.get), + state = { ...shared, active: pageId } + const [frame, setFrame] = useState(null), + [input, setInput] = useState(''), + [busy, setBusy] = useState(false), + [mode, setMode] = useState(false), + [hover, setHover] = useState(null), + [drag, setDrag] = useState(null) + const [draft, setDraft] = useState( + () => + readDraft(draftKey(sid, pageId)) || + ((!readDraft(sid)?.capture?.tabId && shared.active === pageId) || + readDraft(sid)?.capture?.tabId === pageId + ? readDraft(sid) + : null), + ), + [list, setList] = useState(false), + [notice, setNotice] = useState(''), + [discoveries, setDiscoveries] = useState(null), + [history, setHistory] = useState(false) + const [searchEngine, setSearchEngine] = useState(savedSearchEngine), + [spaceHeld, setSpaceHeld] = useState(false) + const root = useRef(null), + spaceHeldRef = useRef(false), + holdControl = useRef(null), + hoverEpoch = useRef(0), + interactionState = useRef(null) + const editing = Boolean(draft) && !spaceHeld, + annotationActive = mode || Boolean(draft), + selecting = annotationActive && !spaceHeld + const addressInput = useRef(null), + startInput = useRef(null), + remoteInput = useRef(null), + viewport = useRef(null), + image = useRef(null), + frameRef = useRef(null), + actionQueue = useRef(Promise.resolve()), + hoverPending = useRef(false), + hoverAt = useRef(0), + wheel = useRef({ x: 0, y: 0, timer: null }), + pointer = useRef(null), + pickEpoch = useRef(0), + pickPending = useRef(false), + frameRefresh = useRef(null) + const active = state.tabs.find((t) => t.id === state.active), + startPage = !active || active.url === 'about:blank' + interactionState.current = { + enabled: annotationActive && visible && !state.sending && !startPage, + hasDraft: Boolean(draft), + } + const chooseSearch = (value) => { + setSearchEngine(value) + try { + localStorage.setItem('page-review:search-engine', value) + } catch {} + } + useEffect(() => { + if (visible && model.get().active !== pageId) { + model.set({ active: pageId }) + void api('/browser', { + session: sid, + kind: 'select', + tabId: pageId, + }).catch((e) => model.set({ error: e.message })) + } + }, [visible, pageId, sid]) + useEffect(() => { + setInput(startPage ? '' : active?.url || '') + setFrame(null) + frameRef.current = null + setHover(null) + if (startPage) setMode(false) + }, [state.active, startPage]) + useEffect(() => { + if (visible && startPage) startInput.current?.focus() + }, [visible, startPage]) + useEffect(() => { + localDraft(draftKey(sid, pageId), draft) + if (readDraft(sid)?.capture?.tabId === pageId) localDraft(sid, null) + }, [draft, sid, pageId]) + useEffect(() => { + holdControl.current = holdToBrowse({ + root: root.current, + surface: () => viewport.current?.querySelector('.pr-screen'), + window, + document, + enabled: () => interactionState.current.enabled, + remoteInput: () => remoteInput.current, + onChange: (value) => { + spaceHeldRef.current = value + presentTemporaryBrowsing(root.current, value) + if (remoteInput.current) + remoteInput.current.readOnly = + !value && interactionState.current.enabled + if (value) { + remoteInput.current?.focus({ preventScroll: true }) + frameRefresh.current?.() + } else if (interactionState.current.hasDraft) + root.current + ?.querySelector('.pr-draft textarea') + ?.focus({ preventScroll: true }) + setSpaceHeld(value) + }, + onInterrupt: (paused) => { + if (paused) { + pointer.current = null + pickEpoch.current++ + if (pickPending.current) { + pickPending.current = false + setBusy(false) + } + } + hoverEpoch.current++ + hoverAt.current = 0 + setHover(null) + setDrag(null) + }, + }) + return () => { + holdControl.current?.dispose() + holdControl.current = null + } + }, [sid]) + useEffect(() => { + if (!annotationActive || state.sending || !visible || startPage) + holdControl.current?.release() + }, [annotationActive, state.sending, visible, startPage]) + const fail = (e, errorSource = 'action') => { + model.set({ error: e.message || String(e), errorSource }) + setBusy(false) + } + const browser = (kind, params = {}) => { + const tabId = params.tabId || pageId + const work = actionQueue.current + .catch(() => {}) + .then(() => + api('/browser', { session: sid, kind, tabId, searchEngine, ...params }), + ) + .then((value) => { + model.set({ ...value, error: '' }) + frameRefresh.current?.() + return value + }) + actionQueue.current = work.catch(() => {}) + work.catch((e) => fail(e)) + return work + } + useEffect(() => { + if (!state.active || startPage || !visible) return + let cancelled = false + const pump = createFramePump({ + read: async () => { + if (document.visibilityState === 'hidden' || pickPending.current) + return null + const epoch = pickEpoch.current + const f = await get('/frame', { session: sid, tab: state.active }) + return epoch === pickEpoch.current && !pickPending.current ? f : null + }, + present: async (f) => { + if (cancelled) return + const current = model.get() + if ( + f.browserState && + JSON.stringify(f.browserState) !== + JSON.stringify({ + tabs: current.tabs, + active: current.active, + visits: current.visits, + }) + ) + model.set(f.browserState) + const previous = frameRef.current + if (previous?.data !== f.data) { + const loader = new Image() + loader.src = 'data:image/jpeg;base64,' + f.data + await loader.decode() + } + if (cancelled) return + frameRef.current = f + if ( + previous?.data !== f.data || + JSON.stringify(previous?.marks) !== JSON.stringify(f.marks) || + previous?.url !== f.url || + previous?.tabId !== f.tabId + ) + setFrame(f) + if (!pickPending.current) setBusy(false) + if (model.get().errorSource === 'frame') + model.set({ error: '', errorSource: null }) + if (document.activeElement?.getAttribute('data-pr-address') !== 'true') + setInput(f.url) + }, + onError: (e) => { + if (!cancelled) fail(e, 'frame') + }, + interval: () => + document.visibilityState === 'hidden' + ? 1200 + : interactionState.current.hasDraft && !spaceHeldRef.current + ? 750 + : 180, + }) + frameRefresh.current = pump.request + void pump.request() + return () => { + cancelled = true + pump.stop() + if (frameRefresh.current === pump.request) frameRefresh.current = null + } + }, [state.active, startPage, visible, sid]) + useEffect( + () => + tabInfo?.tab.actions.bindCommands( + active && !startPage && !draft + ? { + refresh: () => { + browser('reload').catch(() => {}) + }, + } + : {}, + ), + [tabInfo?.tab.actions, state.active, startPage, Boolean(draft)], + ) + useEffect(() => { + const el = viewport.current + if (!el || !state.active || !visible) return + let timer + const observer = new ResizeObserver((entries) => { + const r = entries[0].contentRect + clearTimeout(timer) + timer = setTimeout(() => { + if (r.width > 0 && r.height > 0) + browser('viewport', { width: r.width, height: r.height }).catch( + () => {}, + ) + }, 300) + }) + observer.observe(el) + return () => { + observer.disconnect() + clearTimeout(timer) + } + }, [state.active, sid, visible]) + useEffect(() => { + const onKey = (e) => { + if (!visible || e.defaultPrevented) return + if (e.key === 'Escape') { + if (draft) { + if (draft.comment.trim()) { + setNotice(t('annotation.not.saved.your.text.was.kept')) + return + } + setDraft(null) + return + } + setMode(false) + setHover(null) + setList(false) + setHistory(false) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [draft, visible]) + const navigate = async (url = input) => { + if (draft) { + setNotice(t('save.or.cancel.the.current.annotation.first')) + return + } + setBusy(true) + setMode(false) + try { + await browser(active ? 'navigate' : 'create', { url }) + setDiscoveries(null) + } catch { + } finally { + setBusy(false) + } + } + const newTab = async () => { + if (draft || state.sending) return + setBusy(true) + setDiscoveries(null) + setMode(false) + try { + await browser('create', { url: '' }) + } catch { + } finally { + setBusy(false) + } + } + const onBrowserShortcut = (e) => { + if (!(e.metaKey || e.ctrlKey) || draft) return + const key = e.key.toLowerCase() + if (key === 'l') { + e.preventDefault() + e.stopPropagation() + addressInput.current?.focus() + addressInput.current?.select() + } else if (key === 't') { + e.preventDefault() + e.stopPropagation() + newTab() + } + } + const pos = (e) => { + const rect = image.current?.getBoundingClientRect(), + f = frameRef.current + if (!rect || !f) return null + return { + x: Math.max( + 0, + Math.min(f.width - 1, ((e.clientX - rect.left) * f.width) / rect.width), + ), + y: Math.max( + 0, + Math.min( + f.height - 1, + ((e.clientY - rect.top) * f.height) / rect.height, + ), + ), + } + } + const pick = async (point, region) => { + const epoch = ++pickEpoch.current + pickPending.current = true + setBusy(true) + setHover(null) + try { + const capture = await api('/pick', { + session: sid, + tabId: state.active, + ...point, + region, + frameURL: frameRef.current?.url, + }) + if (epoch === pickEpoch.current) + setDraft({ capture, anchor: point, comment: '', noteId: null }) + } catch (e) { + if (epoch === pickEpoch.current) fail(e) + } finally { + if (epoch === pickEpoch.current) { + pickPending.current = false + setBusy(false) + frameRefresh.current?.() + } + } + } + const onPointerDown = (e) => { + if (!frameRef.current || (draft && !spaceHeldRef.current) || busy) return + remoteInput.current?.focus({ preventScroll: true }) + const p = pos(e) + if (!p) return + pointer.current = { + ...p, + clientX: e.clientX, + clientY: e.clientY, + selecting: mode && !spaceHeldRef.current, + } + if (pointer.current.selecting) { + e.preventDefault() + e.currentTarget.setPointerCapture(e.pointerId) + } + } + const onPointerMove = (e) => { + if (!mode || spaceHeldRef.current || draft || !frame) return + const p = pos(e) + if (!p) return + if (pointer.current) { + const a = pointer.current + setDrag({ + x: Math.min(a.x, p.x), + y: Math.min(a.y, p.y), + width: Math.abs(a.x - p.x), + height: Math.abs(a.y - p.y), + }) + return + } + if (hoverPending.current || Date.now() - hoverAt.current < 140) return + hoverPending.current = true + hoverAt.current = Date.now() + const epoch = hoverEpoch.current + api('/hover', { session: sid, tabId: state.active, ...p }) + .then((value) => { + if ( + epoch === hoverEpoch.current && + !spaceHeldRef.current && + interactionState.current.enabled + ) + setHover(value) + }) + .catch(() => {}) + .finally(() => (hoverPending.current = false)) + } + const onPointerUp = (e) => { + if ( + !pointer.current || + busy || + (draft && !spaceHeldRef.current && pointer.current.selecting) + ) + return + const a = pointer.current, + p = pos(e) + pointer.current = null + setDrag(null) + if (!p) return + if (a.selecting) { + const region = + Math.hypot(a.x - p.x, a.y - p.y) > 7 + ? { + x: Math.min(a.x, p.x), + y: Math.min(a.y, p.y), + width: Math.abs(a.x - p.x), + height: Math.abs(a.y - p.y), + } + : undefined + void pick( + region + ? { x: region.x + region.width / 2, y: region.y + region.height / 2 } + : p, + region, + ) + } else { + const distance = Math.hypot(a.x - p.x, a.y - p.y) + browser(distance > 5 ? 'drag' : 'click', { + ...p, + from: a, + middle: e.button === 1, + modifiers: [ + e.metaKey && 'Meta', + e.ctrlKey && 'Control', + e.shiftKey && 'Shift', + e.altKey && 'Alt', + ].filter(Boolean), + }).catch(() => {}) + } + } + const onWheel = (e) => { + if ( + (draft && !spaceHeldRef.current) || + e.target.closest?.('.pr-list,.pr-services,.pr-empty') + ) + return + e.preventDefault() + wheel.current.x += e.deltaX + wheel.current.y += e.deltaY + if (!wheel.current.timer) + wheel.current.timer = setTimeout(() => { + const { x: dx, y: dy } = wheel.current + wheel.current = { x: 0, y: 0, timer: null } + browser('wheel', { dx, dy }).catch(() => {}) + }, 90) + } + useEffect(() => { + const el = viewport.current + if (!el) return + el.addEventListener('wheel', onWheel, { passive: false }) + return () => { + el.removeEventListener('wheel', onWheel) + clearTimeout(wheel.current.timer) + } + }, [draft?.capture?.id, state.active]) + const onKeyDown = (e) => { + if ( + (e.target !== e.currentTarget && e.target !== remoteInput.current) || + (annotationActive && !spaceHeldRef.current) || + (draft && !spaceHeldRef.current) || + e.nativeEvent.isComposing + ) + return + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'v') return + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c') { + e.preventDefault() + browser('copy') + .then((value) => navigator.clipboard.writeText(value.clipboard)) + .catch(fail) + return + } + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') { + e.preventDefault() + browser('key', { key: 'SelectAll' }).catch(() => {}) + return + } + if ( + [ + 'Enter', + 'Tab', + 'Backspace', + 'Delete', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'Escape', + 'Home', + 'End', + 'PageUp', + 'PageDown', + ].includes(e.key) + ) { + e.preventDefault() + browser('key', { key: e.key }).catch(() => {}) + } else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault() + browser('text', { text: e.key }).catch(() => {}) + } + } + const saveNote = async () => { + if (!draft?.comment.trim()) return + setBusy(true) + try { + const value = await api('/note', { + session: sid, + captureId: draft.capture.id, + id: draft.noteId, + comment: draft.comment, + }) + model.set({ ...value, error: '' }) + setDraft(null) + setNotice(t('saved.select.another.element.to.add.a.comment')) + } catch (e) { + fail(e) + } finally { + setBusy(false) + } + } + const editNote = async (note) => { + if (note.document) { + model.set({ editRequest: { id: crypto.randomUUID(), noteId: note.id } }) + sidebarService.openResourceIn(sid, note.document.resourceAddress) + return + } + setDraft({ + capture: { + id: note.captureId, + tabId: note.tabId, + element: note.element, + url: note.url, + rect: note.rect, + width: note.width || 1100, + height: note.height || 780, + }, + comment: note.comment, + noteId: note.id, + }) + setList(false) + setHistory(false) + } + const removeNote = async (note) => { + try { + model.set( + await api('/note', { session: sid, kind: 'delete', id: note.id }), + ) + setNotice(t('annotation.deleted')) + } catch (e) { + fail(e) + } + } + const restoreNote = async (note) => { + try { + model.set( + await api('/note', { + session: sid, + captureId: note.captureId, + comment: note.comment, + }), + ) + setHistory(false) + setList(true) + setNotice(t('restored.to.pending.annotations')) + } catch (e) { + fail(e) + } + } + const loadDiscover = async () => { + setBusy(true) + try { + setDiscoveries(await get('/discover', {})) + } catch (e) { + fail(e) + } finally { + setBusy(false) + } + } + useEffect(() => { + const request = state.editRequest + if (!request) return + const note = state.notes.find((n) => n.id === request.noteId) + if (!note || (request.pageId || note.tabId) !== pageId) return + model.set({ editRequest: null }) + void editNote(note) + }, [state.editRequest?.id]) + const frameStyle = (r) => + frame + ? { + left: `${(r.x / frame.width) * 100}%`, + top: `${(r.y / frame.height) * 100}%`, + width: `${(r.width / frame.width) * 100}%`, + height: `${(r.height / frame.height) * 100}%`, + } + : {} + const frozen = draft ? imageURL(sid, draft.capture.id) : null + return ( +
+
+ browser('back').catch(() => {})} + /> + browser('forward').catch(() => {})} + /> + browser('reload').catch(() => {})} + /> + navigate('about:blank')} + /> +
{ + e.preventDefault() + navigate() + }} + > + + e.target.select()} + value={input} + onChange={(e) => setInput(e.target.value)} + disabled={Boolean(draft)} + /> + + + + { + setHistory(false) + setList(!list) + }} + /> + { + setList(false) + setHistory(!history) + }} + /> + + window.open(active.url, '_blank', 'noopener,noreferrer') + } + /> +
+ {(state.error || active?.error) && ( +
+ {state.error || active.error} + +
+ )} + {notice && ( +
setNotice('')}> + {notice} + × +
+ )} +
{ + if ( + (e.target === e.currentTarget || + e.target === remoteInput.current) && + !(annotationActive && !spaceHeldRef.current) && + !(draft && !spaceHeldRef.current) + ) { + e.preventDefault() + browser('text', { text: e.clipboardData.getData('text') }).catch( + () => {}, + ) + } + }} + onCompositionEnd={(e) => { + if ( + (e.target === e.currentTarget || + e.target === remoteInput.current) && + !(annotationActive && !spaceHeldRef.current) && + !(draft && !spaceHeldRef.current) + ) { + browser('text', { text: e.data }).catch(() => {}) + if (remoteInput.current) remoteInput.current.value = '' + } + }} + > + {active && ( +
文字
', + ) + const { window: w } = dom, + d = w.document + let enabled = true, + held = false, + changes = 0, + interrupts = 0 + const control = holdToBrowse({ + root: d.querySelector('#browser'), + surface: () => d.querySelector('#page'), + window: w, + document: d, + enabled: () => enabled, + remoteInput: () => d.querySelector('#remote'), + onChange: (value) => { + presentTemporaryBrowsing(d.querySelector('#browser'), value) + held = value + changes++ + }, + onInterrupt: () => interrupts++, + }) + const pointer = (target) => + d + .querySelector(target) + .dispatchEvent(new w.MouseEvent('pointermove', { bubbles: true })) + const key = (target, type = 'keydown', extra = {}) => { + const event = new w.KeyboardEvent(type, { + key: ' ', + code: 'Space', + bubbles: true, + cancelable: true, + ...extra, + }) + d.querySelector(target).dispatchEvent(event) + return event + } + return { + dom, + w, + d, + control, + key, + pointer, + get held() { + return held + }, + get changes() { + return changes + }, + get interrupts() { + return interrupts + }, + disable: () => (enabled = false), + } +} +test('按住空格暂停标注,重复按键不反复切换,松开即恢复', () => { + const f = setup() + try { + assert.equal(f.key('#mode').defaultPrevented, true) + assert.equal(f.held, true) + f.key('#mode', 'keydown', { repeat: true }) + assert.equal(f.changes, 1) + assert.equal(f.interrupts, 1) + f.key('#mode', 'keyup') + assert.equal(f.held, false) + assert.equal(f.changes, 2) + } finally { + f.control.dispose() + f.dom.window.close() + } +}) +test('聊天、地址栏、批注文字、富文本、组合键和输入法不触发临时浏览', () => { + const f = setup() + try { + for (const target of ['#chat', '#address', '#note', '#leaf']) { + assert.equal(f.key(target).defaultPrevented, false) + assert.equal(f.held, false) + } + for (const extra of [ + { ctrlKey: true }, + { metaKey: true }, + { altKey: true }, + { shiftKey: true }, + { isComposing: true }, + { repeat: true }, + ]) { + f.key('#page', 'keydown', extra) + assert.equal(f.held, false) + } + f.disable() + assert.equal(f.key('#page').defaultPrevented, false) + } finally { + f.control.dispose() + f.dom.window.close() + } +}) +test('网页键盘代理可以临时浏览,点击使焦点离开后抬键仍恢复', () => { + const f = setup() + try { + f.key('#remote') + assert.equal(f.held, true) + f.key('#chat', 'keyup') + assert.equal(f.held, false) + } finally { + f.control.dispose() + f.dom.window.close() + } +}) +test('窗口失焦、页面隐藏、切换模式及卸载都释放空格,不遗留监听器', () => { + const f = setup() + try { + f.key('#page') + f.w.dispatchEvent(new f.w.Event('blur')) + assert.equal(f.held, false) + f.key('#page') + Object.defineProperty(f.d, 'visibilityState', { + value: 'hidden', + configurable: true, + }) + f.d.dispatchEvent(new f.w.Event('visibilitychange')) + assert.equal(f.held, false) + f.key('#page') + f.control.release() + assert.equal(f.held, false) + f.key('#page') + f.control.dispose() + assert.equal(f.held, false) + assert.equal(f.key('#page').defaultPrevented, false) + } finally { + f.dom.window.close() + } +}) + +test('鼠标回到网页后,不受聊天输入框或页面外遗留键盘焦点限制', () => { + const f = setup() + try { + f.pointer('#page') + assert.equal(f.key('#chat').defaultPrevented, true) + assert.equal(f.held, true) + f.key('#chat', 'keyup') + assert.equal(f.held, false) + f.pointer('#page') + assert.equal(f.key('body').defaultPrevented, true) + f.key('body', 'keyup') + assert.equal(f.held, false) + } finally { + f.control.dispose() + f.dom.window.close() + } +}) +test('批注气泡自动聚焦后可按空格浏览,实际开始写批注后空格正常输入', () => { + const f = setup() + try { + f.pointer('#page') + f.d.querySelector('#note').focus() + assert.equal(f.key('#note').defaultPrevented, true) + assert.equal(f.held, true) + f.key('#note', 'keyup') + f.key('#note', 'keydown', { key: 'a', code: 'KeyA' }) + assert.equal(f.key('#note').defaultPrevented, false) + assert.equal(f.held, false) + f.pointer('#page') + assert.equal(f.key('#note').defaultPrevented, true) + f.key('#note', 'keyup') + } finally { + f.control.dispose() + f.dom.window.close() + } +}) +test('点击输入区或输入法、粘贴输入会恢复文字输入意图,不抢空格', () => { + const f = setup() + try { + f.pointer('#page') + f.pointer('#address') + assert.equal(f.key('#address').defaultPrevented, false) + f.pointer('#page') + f.d + .querySelector('#note') + .dispatchEvent(new f.w.Event('input', { bubbles: true })) + assert.equal(f.key('#note').defaultPrevented, false) + f.pointer('#page') + assert.equal( + f.key('#note', 'keydown', { isComposing: true }).defaultPrevented, + false, + ) + } finally { + f.control.dispose() + f.dom.window.close() + } +}) + +test('滚动网页也会将操作意图切回网页,不要求键盘焦点先离开聊天', () => { + const f = setup() + try { + f.d.querySelector('#chat').focus() + f.d + .querySelector('#page') + .dispatchEvent(new f.w.WheelEvent('wheel', { bubbles: true, deltaY: 10 })) + assert.equal(f.key('#chat').defaultPrevented, true) + assert.equal(f.held, true) + f.key('#chat', 'keyup') + assert.equal(f.held, false) + } finally { + f.control.dispose() + f.dom.window.close() + } +}) + +test('按下与松开的同一个事件内即更新显示状态,不等待异步渲染或截图', () => { + const f = setup() + try { + const root = f.d.querySelector('#browser') + f.pointer('#page') + f.key('#chat') + assert.equal(root.getAttribute('data-temp-browsing'), 'true') + f.key('#chat', 'keyup') + assert.equal(root.hasAttribute('data-temp-browsing'), false) + f.key('#page') + f.control.dispose() + assert.equal(root.hasAttribute('data-temp-browsing'), false) + } finally { + f.dom.window.close() + } +}) diff --git a/packages/dsh-page-review/tests/links.test.mjs b/packages/dsh-page-review/tests/links.test.mjs new file mode 100644 index 000000000..11805786b --- /dev/null +++ b/packages/dsh-page-review/tests/links.test.mjs @@ -0,0 +1,175 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { JSDOM, VirtualConsole } from 'jsdom' +const browserDOM = (html, options) => + new JSDOM(html, { ...options, virtualConsole: new VirtualConsole() }) +import { webLink, routeWebLinks, createLinkOpener } from '../src/links.mjs' +const drain = () => new Promise((r) => setImmediate(r)) +test('网页链接支持 HTTP(S),保留站内导航、下载与非网页协议', async () => { + const dom = browserDOM( + '网页本地网页站内邮件下载', + { url: 'http://127.0.0.1:43129/' }, + ), + calls = [] + const doc = dom.window.document, + dispose = routeWebLinks({ + document: doc, + origin: dom.window.location.origin, + sessionId: () => 's', + open: async (...a) => calls.push(a), + onError: () => assert.fail(), + }) + const dispatch = (id, button = 0, extras = {}) => { + const e = new dom.window.MouseEvent(button === 1 ? 'auxclick' : 'click', { + bubbles: true, + cancelable: true, + button, + ...extras, + }) + doc.querySelector(id).dispatchEvent(e) + return e.defaultPrevented + } + assert.equal(dispatch('#web span'), true) + assert.equal(dispatch('#local', 1), true) + for (const id of ['same', 'mail', 'file']) + assert.equal(dispatch('#' + id), false) + await drain() + assert.deepEqual(calls, [ + ['s', 'https://example.com/docs'], + ['s', 'http://localhost:3000/'], + ]) + dispose() + dom.window.close() +}) +test('键盘点击和组合点击仍使用内部网页,显式外部链接及无会话不接管', async () => { + const dom = browserDOM( + '网站外部', + { url: 'http://127.0.0.1:43129/' }, + ) + let sid = 's', + calls = 0 + const doc = dom.window.document + const dispose = routeWebLinks({ + document: doc, + origin: dom.window.location.origin, + sessionId: () => sid, + open: async () => calls++, + onError: () => assert.fail(), + }) + for (const mods of [{}, { ctrlKey: true }, { metaKey: true }]) { + const e = new dom.window.MouseEvent('click', { + bubbles: true, + cancelable: true, + ...mods, + }) + doc.querySelector('#a').dispatchEvent(e) + assert.equal(e.defaultPrevented, true) + } + const external = new dom.window.MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + doc.querySelector('#b').dispatchEvent(external) + assert.equal(external.defaultPrevented, false) + sid = null + const noSession = new dom.window.MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + doc.querySelector('#a').dispatchEvent(noSession) + assert.equal(noSession.defaultPrevented, false) + await drain() + assert.equal(calls, 3) + dispose() + dom.window.close() +}) +test('链接请求失败给出错误,不自动跳往外部浏览器;卸载会撤销监听', async () => { + const dom = browserDOM('网站', { + url: 'http://127.0.0.1:43129/', + }), + errors = [] + const doc = dom.window.document + const dispose = routeWebLinks({ + document: doc, + origin: dom.window.location.origin, + sessionId: () => 's', + open: async () => { + throw Error('打开失败') + }, + onError: (e, sid) => errors.push([e.message, sid]), + }) + doc + .querySelector('a') + .dispatchEvent( + new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }), + ) + await drain() + assert.deepEqual(errors, [['打开失败', 's']]) + dispose() + const e = new dom.window.MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + doc.querySelector('a').dispatchEvent(e) + assert.equal(e.defaultPrevented, false) + dom.window.close() +}) +test('并发点击同一链接只创建一个网页,后续点击切回已有页面', async () => { + const state = { tabs: [], active: null }, + shown = [] + let creates = 0, + loads = 0 + const open = createLinkOpener({ + load: async () => loads++, + read: () => state, + create: async (sid, url) => { + creates++ + const t = { id: 'page', url } + state.tabs.push(t) + state.active = t.id + return state + }, + select: async (sid, id) => { + state.active = id + return state + }, + show: (...a) => shown.push(a), + }) + await Promise.all([ + open('s', 'https://example.com'), + open('s', 'https://example.com/'), + ]) + assert.equal(creates, 1) + assert.equal(loads, 2) + assert.deepEqual( + shown.map((a) => a.slice(0, 2)), + [ + ['s', 'page'], + ['s', 'page'], + ], + ) +}) +test('链接打开保留替换入口参数,失败不堵住后续链接;拒绝凭据和脚本 URL', async () => { + let count = 0 + const shown = [] + const open = createLinkOpener({ + load: async () => {}, + read: () => ({ tabs: [] }), + create: async () => { + if (++count === 1) throw Error('断网') + return { active: 'new' } + }, + show: (...args) => shown.push(args), + }) + await assert.rejects(open('s', 'https://example.com'), /断网/) + await open('s', 'http://localhost:3000/', { replaceTab: 'launcher' }) + assert.deepEqual(shown[0], ['s', 'new', { replaceTab: 'launcher' }]) + for (const url of [ + 'javascript:alert(1)', + 'file:///tmp/a', + 'https://user:pass@example.com', + ]) { + assert.equal(webLink(url), null) + await assert.rejects(open('s', url), /HTTP/) + } +}) diff --git a/packages/dsh-page-review/tests/placement.test.mjs b/packages/dsh-page-review/tests/placement.test.mjs new file mode 100644 index 000000000..fdb221f3b --- /dev/null +++ b/packages/dsh-page-review/tests/placement.test.mjs @@ -0,0 +1,80 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { annotationPlacement } from '../src/placement.mjs' +const place = (rect, extra = {}) => + annotationPlacement({ + viewport: { width: 800, height: 600 }, + capture: { width: 800, height: 600, rect }, + popup: { width: 360, height: 220 }, + ...extra, + }) + +test('批注气泡紧贴元素下方,底部元素自动放到上方', () => { + const below = place({ x: 100, y: 100, width: 180, height: 40 }) + assert.equal(below.side, 'bottom') + assert.equal(below.left, 100) + assert.equal(below.top, 150) + const above = place({ x: 100, y: 530, width: 180, height: 40 }) + assert.equal(above.side, 'top') + assert.equal(above.top, 300) +}) +test('截图缩放和留白后的选区位置与气泡对齐', () => { + const position = place( + { x: 100, y: 100, width: 200, height: 40 }, + { + viewport: { width: 600, height: 600 }, + capture: { + width: 1000, + height: 500, + rect: { x: 100, y: 100, width: 200, height: 40 }, + }, + }, + ) + assert.equal(position.left, 60) + assert.equal(position.top, 244) // y: 150 letterbox + 140*.6 + gap +}) +test('窄侧栏、四角和放大选区均不会将输入区推出视口', () => { + for (const [vw, vh] of [ + [400, 700], + [737, 827], + [300, 220], + ]) + for (const [x, y] of [ + [0, 0], + [900, 0], + [0, 650], + [900, 650], + ]) { + const p = annotationPlacement({ + viewport: { width: vw, height: vh }, + capture: { + width: 1000, + height: 700, + rect: { x, y, width: 100, height: 50 }, + }, + popup: { width: 360, height: 230 }, + }) + assert.ok(p.left >= 12) + assert.ok(p.top >= 12) + assert.ok(p.left + p.width <= vw - 12 + 0.001) + assert.ok(p.top + Math.min(230, p.maxHeight) <= vh - 12 + 0.001) + } +}) +test('整页根元素使用实际点击处作为锚点,避免跳到页面另一端', () => { + const p = place( + { x: 0, y: 0, width: 800, height: 600 }, + { point: { x: 120, y: 100 } }, + ) + assert.equal(p.side, 'bottom') + assert.equal(p.top, 110) + assert.equal(p.left, 120) +}) + +test('上下空间不足的长元素,输入气泡选择可容纳它的左右侧', () => { + const right = place({ x: 100, y: 100, width: 40, height: 400 }) + assert.equal(right.side, 'right') + assert.equal(right.left, 150) + const left = place({ x: 660, y: 100, width: 40, height: 400 }) + assert.equal(left.side, 'left') + assert.equal(left.left, 290) +}) diff --git a/packages/dsh-page-review/tests/review.test.mjs b/packages/dsh-page-review/tests/review.test.mjs new file mode 100644 index 000000000..a3fb45a39 --- /dev/null +++ b/packages/dsh-page-review/tests/review.test.mjs @@ -0,0 +1,250 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { buildPacket, acceptedVersions, deliverPacket } from '../src/packet.mjs' +import { ReviewEngine, normalizeURL, sessionKey } from '../src/engine.mjs' +import { inspectPoint } from '../src/inspect.mjs' +import { trusted } from '../src/host.mjs' +import { JSDOM } from 'jsdom' +const sid = 'review-test-session' +const note = { + id: 'a', + sessionId: sid, + captureId: 'image-a', + comment: '让按钮和输入框左对齐', + updatedAt: 'v1', +} +const capture = { + sessionId: sid, + data: Buffer.from([255, 216, 255, 217]).toString('base64'), + width: 960, + height: 600, + url: 'http://localhost:3000/login', + createdAt: '2026-10-20T00:00:00Z', + element: { + tag: 'button', + text: '登录', + selector: '#login', + matches: 1, + html: '', + }, + rect: { x: 40, y: 80, width: 100, height: 30 }, + scroll: { x: 0, y: 0 }, +} +test('模型输入同时包含真实图片块、元素定位、批注和截图时间,编号一致', () => { + const p = buildPacket([note], () => capture) + assert.equal(p.images.length, 1) + assert.equal(p.images[0].type, 'image') + assert.equal(p.images[0].mediaType, 'image/jpeg') + assert.equal(p.images[0].data, capture.data) + assert.match(p.text, /#login/) + assert.match(p.text, /让按钮和输入框左对齐/) + assert.match(p.text, /网页批注-1.jpg/) + assert.match(p.text, /2026-10-20/) + assert.match(p.text, /不可信引用资料/) +}) +test('缺失截图或跨会话截图必须阻止发送,不能降级成仅文本', () => { + assert.throws(() => buildPacket([note], () => null), /截图缺失/) + assert.throws( + () => buildPacket([note], () => ({ ...capture, sessionId: 'another' })), + /截图缺失/, + ) +}) +test('失败重试沿用同一个 requestId,明确回执后才成功', async () => { + let creates = 0, + calls = 0, + received + const attempt = {} + const session = { + beginSubmission: () => { + creates++ + return { requestId: 'same-request' } + }, + prompt: async (...args) => { + calls++ + received = args + if (calls === 1) throw Error('网络断开') + return { ok: true, value: { accepted: true } } + }, + } + const p = buildPacket([note], () => capture) + await assert.rejects(() => deliverPacket(session, p, attempt), /网络断开/) + await deliverPacket(session, p, attempt) + assert.equal(creates, 1) + assert.equal(received[3], 'same-request') + assert.equal(received[0][1].type, 'image') + assert.equal(received[0][1].data, capture.data) + await assert.rejects( + () => + deliverPacket( + { + ...session, + prompt: async () => ({ ok: true, value: { accepted: false } }), + }, + p, + {}, + ), + /未确认接收/, + ) +}) +test('发送中的编辑不会被迟到的回执清空', () => { + const versions = [{ id: 'a', updatedAt: 'v1', comment: note.comment }] + assert.equal(acceptedVersions([note], versions).length, 0) + assert.equal( + acceptedVersions( + [{ ...note, comment: '新增要求', updatedAt: 'v2' }], + versions, + ).length, + 1, + ) +}) +test('浏览器仅接受 HTTP(S),拒绝宿主自身和凭据 URL', () => { + assert.equal(normalizeURL('8016'), 'http://127.0.0.1:8016/') + assert.equal(normalizeURL('localhost:3000'), 'http://localhost:3000/') + assert.throws(() => normalizeURL('http://user:pass@localhost:3000'), /凭据/) + assert.throws( + () => normalizeURL('http://localhost:3000', 'http://localhost:3000'), + /DSH/, + ) + for (const key of ['../../secret', '.', '..']) + assert.throws(() => sessionKey(key)) + assert.throws(() => normalizeURL('javascript:alert(1)')) + assert.throws(() => normalizeURL('ftp://example.com'), /HTTP/) +}) +test('变更路由只接受同源回环请求', () => { + const req = { + method: 'POST', + socket: { remoteAddress: '127.0.0.1' }, + headers: { host: '127.0.0.1:43129', origin: 'http://127.0.0.1:43129' }, + } + assert.equal(trusted(req), true) + assert.equal( + trusted({ + ...req, + headers: { ...req.headers, origin: 'https://evil.example' }, + }), + false, + ) + assert.equal( + trusted({ ...req, socket: { remoteAddress: '192.168.1.2' } }), + false, + ) + assert.equal(trusted({ ...req, headers: { host: '127.0.0.1:43129' } }), false) +}) +test('选择器唯一命中且 DOM 引用不包含输入框密码', () => { + const dom = new JSDOM( + '', + { url: 'http://localhost:3000' }, + ) + const w = dom.window + w.CSS = { escape: (s) => s } + w.document.elementFromPoint = () => + w.document.querySelector('button[data-testid]') + const fn = new Function( + 'document', + 'CSS', + 'getComputedStyle', + 'innerWidth', + 'scrollX', + 'scrollY', + 'location', + `return (${inspectPoint.toString()})(1,1)`, + ) + const result = fn( + w.document, + w.CSS, + w.getComputedStyle, + 960, + 0, + 0, + w.location, + ) + assert.equal(result.element.matches, 1) + assert.match(result.element.selector, /data-testid/) + w.document.elementFromPoint = () => w.document.querySelector('input') + const input = fn(w.document, w.CSS, w.getComputedStyle, 960, 0, 0, w.location) + assert.doesNotMatch(input.element.html, /secret/) + dom.window.close() +}) +test('发送确认与草稿跨重启保留,重复回执不清空新批注', async () => { + const root = await mkdtemp(join(tmpdir(), 'page-review-test-')) + try { + const engine = new ReviewEngine(root), + s = await engine.session(sid) + s.notes = [note] + s.prepared.batch = { + versions: [{ id: 'a', updatedAt: 'v1', comment: note.comment }], + } + await engine.save(s) + const reopened = new ReviewEngine(root), + s2 = await reopened.session(sid) + assert.equal(s2.notes[0].comment, note.comment) + // Valid delivery UUID, matching production path validation. + const id = '00000000-0000-4000-8000-000000000001' + s2.prepared[id] = s2.prepared.batch + delete s2.prepared.batch + await reopened.acknowledge(s2, id) + assert.equal(s2.notes.length, 0) + s2.notes.push({ ...note, id: 'b' }) + await reopened.acknowledge(s2, id) + assert.equal(s2.notes.length, 1) + assert.equal(s2.history.length, 1) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) +test('打开失败保留用户网址,重试成功后清除错误,不保存 Chrome 内部错误地址', async () => { + const engine = new ReviewEngine('/unused') + const s = { + tabs: [], + notes: [], + history: [], + visits: [], + clients: new Map(), + } + const t = { id: 'tab', url: 'https://example.com/' } + let actual = 'chrome-error://chromewebdata/' + const p = { + goto: async () => { + throw Error('net::ERR_NAME_NOT_RESOLVED at https://example.com/') + }, + url: () => actual, + title: async () => '无法访问此网站', + } + await engine.load(t, p, t.url) + await engine.metadata(s, t, p) + assert.equal(t.url, 'https://example.com/') + assert.match(t.error, /ERR_NAME_NOT_RESOLVED/) + assert.equal(s.visits.length, 0) + actual = 'https://example.com/docs' + p.goto = async () => {} + p.title = async () => 'Example Docs' + await engine.load(t, p, actual) + await engine.metadata(s, t, p) + assert.equal(t.error, '') + assert.equal(t.url, actual) + assert.equal(s.visits[0].title, 'Example Docs') +}) +test('旧版本保存的错误标签页恢复成可搜索的新标签页,批注仍保留', async () => { + const root = await mkdtemp(join(tmpdir(), 'browser-migrate-')) + try { + const engine = new ReviewEngine(root), + s = await engine.session(sid) + s.tabs = [ + { id: 'legacy', url: 'chrome-error://chromewebdata/', title: '百度' }, + ] + s.active = 'legacy' + s.notes = [note] + await engine.save(s) + const reopened = new ReviewEngine(root), + s2 = await reopened.session(sid) + assert.equal(s2.tabs[0].url, 'about:blank') + assert.equal(s2.tabs[0].title, '新标签页') + assert.equal(s2.notes[0].comment, note.comment) + assert.deepEqual(s2.visits, []) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/dsh-page-review/tests/submission.test.mjs b/packages/dsh-page-review/tests/submission.test.mjs new file mode 100644 index 000000000..ecd23155c --- /dev/null +++ b/packages/dsh-page-review/tests/submission.test.mjs @@ -0,0 +1,161 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createDirectSender } from '../src/submission.mjs' +import { deliverPacket } from '../src/packet.mjs' +import { retireComposer } from '../src/composer.mjs' + +test('不经过审查或浏览器面板,单次提交直接包含图片、元素和注释', async () => { + const calls = [], + packet = { + text: '用户补充要求:修改按钮\n批注 1\n定位:#submit\n用户要求:改成蓝色', + images: [ + { + type: 'image', + mediaType: 'image/jpeg', + data: 'aW1hZ2U=', + name: '网页批注-1.jpg', + width: 400, + height: 300, + }, + ], + deliveryId: 'd1', + } + const session = { + beginSubmission: (x) => { + calls.push(['begin', x]) + return { requestId: 'one' } + }, + prompt: async (...args) => { + calls.push(['prompt', ...args]) + return { ok: true, value: { accepted: true } } + }, + } + const sender = createDirectSender({ + prepare: async () => packet, + deliver: (p, a) => deliverPacket(session, p, a), + retire: () => calls.push(['retire']), + ack: async () => { + calls.push(['ack']) + return { notes: [] } + }, + }) + await sender.send() + assert.deepEqual( + calls.map((x) => x[0]), + ['begin', 'prompt', 'retire', 'ack'], + ) + assert.deepEqual( + calls[1][1].map((x) => x.type), + ['text', 'image'], + ) + assert.match(calls[1][1][0].text, /#submit/) + assert.match(calls[1][1][0].text, /改成蓝色/) + assert.equal(calls[1][1][1].data, 'aW1hZ2U=') +}) + +test('连续点击和 Enter 共用进行中的请求,不产生重复消息', async () => { + let resume, + prepared = 0, + delivered = 0 + const ready = new Promise((r) => (resume = r)) + const sender = createDirectSender({ + prepare: async () => { + prepared++ + await ready + return {} + }, + deliver: async () => { + delivered++ + }, + retire: () => {}, + ack: async () => ({}), + }) + const first = sender.send(), + second = sender.send() + assert.equal(first, second) + resume() + await first + assert.equal(prepared, 1) + assert.equal(delivered, 1) +}) + +test('发送失败保留原请求及草稿,重试不丢失发送期间新增的文字', async () => { + let prepared = 0, + attempts = 0, + acked = 0, + pruned = [] + const snapshot = { text: '原要求', draftRev: 1, attachmentIds: ['image-a'] }, + current = { draft: '新增要求', draftRev: 2 } + const actions = { + setDraft: () => assert.fail('不能清空新草稿'), + pruneAttachments: (ids) => pruned.push(ids), + } + const sender = createDirectSender({ + prepare: async () => { + prepared++ + return { composer: snapshot } + }, + deliver: async (p, a) => { + a.requestId ??= 'stable-id' + assert.equal(a.requestId, 'stable-id') + if (++attempts === 1) throw Error('响应丢失') + }, + retire: (p) => retireComposer(p.composer, current, actions), + ack: async () => { + acked++ + return {} + }, + }) + await assert.rejects(sender.send(), /响应丢失/) + assert.equal(acked, 0) + assert.deepEqual(pruned, []) + await sender.send() + assert.equal(prepared, 1) + assert.equal(attempts, 2) + assert.equal(acked, 1) + assert.deepEqual(pruned, [['image-a']]) +}) + +test('模型已接收而本地归档失败时,只重试回执,不再次发送', async () => { + let prepared = 0, + delivered = 0, + retired = 0, + acked = 0 + const sender = createDirectSender({ + prepare: async () => { + prepared++ + return {} + }, + deliver: async () => { + delivered++ + }, + retire: () => retired++, + ack: async () => { + if (++acked === 1) throw Error('本地回执失败') + return { notes: [] } + }, + }) + await assert.rejects(sender.send(), /本地回执失败/) + await sender.send() + assert.deepEqual([prepared, delivered, retired, acked], [1, 1, 1, 2]) +}) + +test('准备附件失败可重新准备,不交付半份消息,也不留在发送中', async () => { + let prepared = 0, + delivered = 0, + ended = 0 + const sender = createDirectSender({ + prepare: async () => { + if (++prepared === 1) throw Error('附件未完成') + return {} + }, + deliver: async () => delivered++, + retire: () => {}, + ack: async () => ({}), + onEnd: () => ended++, + }) + await assert.rejects(sender.send(), /附件未完成/) + assert.equal(delivered, 0) + await sender.send() + assert.deepEqual([prepared, delivered, ended], [2, 1, 2]) +}) diff --git a/packages/dsh-page-review/tests/tabs.test.mjs b/packages/dsh-page-review/tests/tabs.test.mjs new file mode 100644 index 000000000..823bcf51a --- /dev/null +++ b/packages/dsh-page-review/tests/tabs.test.mjs @@ -0,0 +1,213 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { browserAddress, browserPage, createBrowserTabs } from '../src/tabs.mjs' +const drain = () => new Promise((r) => setImmediate(r)) +function fixture( + initial = [ + { id: 'a', title: 'A' }, + { id: 'b', title: 'B' }, + ], + live = [], +) { + const sid = 'session-one', + records = [...live], + listeners = new Set(), + opens = [], + errors = [] + let value = { loaded: true, tabs: initial, active: initial.at(-1)?.id }, + closed = [], + bridge, + closeResolve + const model = { + get: () => value, + set: (p) => { + value = { ...value, ...bridge.filter(p) } + for (const f of listeners) f() + }, + subscribe: (f) => { + listeners.add(f) + return () => listeners.delete(f) + }, + } + const native = { + tabsIn: () => records, + openResourceIn: (session, address, options) => { + opens.push({ session, address, options }) + assert.equal(session, sid) + if (options.replaceTab) { + const i = records.findIndex((t) => t.id === options.replaceTab) + if (i >= 0) records.splice(i, 1) + } + if (!records.some((t) => t.contentId === address)) + records.push({ + id: 'native-' + address, + kind: 'page-review', + contentId: address, + }) + }, + } + bridge = createBrowserTabs({ + sessionId: sid, + kind: 'page-review', + native, + model, + create: async () => ({ + tabs: [...value.tabs, { id: 'new', title: '新标签页' }], + active: 'new', + }), + close: (id) => { + closed.push(id) + return new Promise((r) => (closeResolve = r)) + }, + hasDraft: (id) => id === 'draft', + onError: (e) => errors.push(e.message), + }) + return { + bridge, + model, + records, + opens, + errors, + closed, + resolveClose: (state) => closeResolve(state), + info: { panel: { id: 'pane-one' }, tab: { id: 'old-wrapper' } }, + } +} +test('网页地址按对话隔离,拒绝其他类型及对话的资源', () => { + const address = browserAddress('session/一', 'page?1') + assert.equal(browserPage(address, 'session/一'), 'page?1') + assert.equal(browserPage(address, 'different'), null) + assert.equal(browserPage('sidebar://page-review', 'session/一'), null) + assert.equal(browserPage('https://page-review/a/b', 'a'), null) +}) +test('原有网页迁到系统标签,替换浏览器外层,保留原活动页', async () => { + const f = fixture() + await f.bridge.launch(f.info) + await drain() + assert.equal(f.model.get().tabs.length, 2) + assert.equal(f.records.length, 2) + assert.equal(f.opens[0].options.replaceTab, 'old-wrapper') + assert.equal(browserPage(f.opens.at(-1).address, 'session-one'), 'b') + f.bridge.dispose() +}) +test('弹出窗口和新网页只添加一个原生标签,元数据更新不重复开页', async () => { + const f = fixture() + await f.bridge.launch(f.info) + await drain() + f.opens.length = 0 + f.model.set({ + tabs: [...f.model.get().tabs, { id: 'popup' }], + active: 'popup', + }) + await drain() + assert.equal(f.records.length, 3) + assert.equal(browserPage(f.opens.at(-1).address, 'session-one'), 'popup') + f.opens.length = 0 + f.model.set({ + tabs: f.model.get().tabs.map((t) => ({ ...t, title: '更新标题' })), + }) + await drain() + assert.equal(f.opens.length, 0) + f.bridge.dispose() +}) +test('新入口创建新标签,刷新已恢复标签不会额外创建', async () => { + const f = fixture( + [{ id: 'a' }], + [ + { + id: 'native-a', + kind: 'page-review', + contentId: browserAddress('session-one', 'a'), + }, + ], + ) + f.bridge.adopt(f.info) + await drain() + assert.equal(f.model.get().tabs.length, 1) + await f.bridge.launch(f.info) + await drain() + assert.equal(f.model.get().tabs.length, 2) + assert.equal(f.model.get().active, 'new') + f.bridge.dispose() +}) +test('关闭顶部标签也关闭网页,迟到截图元数据不会将标签重新打开', async () => { + const f = fixture() + await f.bridge.launch(f.info) + await drain() + const tab = f.records.find( + (t) => browserPage(t.contentId, 'session-one') === 'b', + ) + f.bridge.beforeClose(tab) + f.records.splice(f.records.indexOf(tab), 1) + f.model.set({ tabs: [{ id: 'a' }, { id: 'b' }] }) + await drain() + assert.deepEqual( + f.model.get().tabs.map((t) => t.id), + ['a'], + ) + assert.deepEqual(f.closed, ['b']) + assert.equal(f.records.length, 1) + f.resolveClose({ tabs: [{ id: 'a' }], active: 'a' }) + await drain() + f.model.set({ tabs: [{ id: 'a' }, { id: 'b' }] }) + await drain() + assert.equal(f.records.length, 1) + assert.equal(f.model.get().tabs.length, 1) + f.bridge.dispose() +}) +test('未保存批注阻止关闭;重复的原生视图不提前关闭共享网页', async () => { + const f = fixture([{ id: 'draft' }]) + await f.bridge.launch(f.info) + await drain() + const tab = f.records[0] + assert.throws(() => f.bridge.beforeClose(tab), /保存或取消/) + assert.equal(f.closed.length, 0) + assert.equal(f.errors.length, 1) + f.records.push({ ...tab, id: 'duplicate' }) + f.bridge.beforeClose(tab) + assert.equal(f.closed.length, 0) + f.bridge.dispose() +}) +test('网页关闭失败恢复标签,保留对应页面与错误提示', async () => { + const f = fixture() + await f.bridge.launch(f.info) + await drain() + const tab = f.records[0] + // Use a rejecting close service to cover recovery independently. + f.bridge.dispose() + let state = { loaded: true, tabs: [{ id: 'a' }], active: 'a' }, + listeners = new Set(), + bridge + const model = { + get: () => state, + set: (p) => { + state = { ...state, ...bridge.filter(p) } + for (const fn of listeners) fn() + }, + subscribe: (fn) => { + listeners.add(fn) + return () => listeners.delete(fn) + }, + } + const live = [ + { id: 'n', kind: 'page-review', contentId: browserAddress('s', 'a') }, + ], + errors = [] + bridge = createBrowserTabs({ + sessionId: 's', + kind: 'page-review', + native: { tabsIn: () => live, openResourceIn: () => {} }, + model, + close: async () => { + throw Error('离线') + }, + hasDraft: () => false, + onError: (e) => errors.push(e.message), + }) + bridge.adopt({ panel: { id: 'p' } }) + bridge.beforeClose(live[0]) + await drain() + assert.equal(state.tabs[0].id, 'a') + assert.deepEqual(errors, ['离线']) + bridge.dispose() +}) diff --git a/packages/dsh-page-review/tests/transcript.test.mjs b/packages/dsh-page-review/tests/transcript.test.mjs new file mode 100644 index 000000000..ac581a0f7 --- /dev/null +++ b/packages/dsh-page-review/tests/transcript.test.mjs @@ -0,0 +1,139 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + annotationTranscript, + ANNOTATION_INSTRUCTIONS, + serializeAnnotations, +} from '../src/transcript.mjs' +import { buildPacket, deliverPacket } from '../src/packet.mjs' +import { withComposerText } from '../src/composer.mjs' +const note = { + id: 'n', + sessionId: 's', + captureId: 'c', + comment: '请把按钮换成蓝色\n保留 强调 和 "引号"', + updatedAt: 'v1', +} +const capture = { + sessionId: 's', + data: 'YWJj', + url: 'http://localhost:3000/', + width: 900, + height: 700, + createdAt: '2026-10-21T00:00:00Z', + rect: { x: 1, y: 2, width: 50, height: 20 }, + scroll: { x: 0, y: 12 }, + element: { + tag: 'button', + text: '提交', + selector: '#submit', + matches: 1, + html: '', + styles: { color: 'red' }, + source: 'src/app.tsx:12', + }, +} +const packet = () => + withComposerText( + buildPacket([note], () => capture), + { text: '这是补充要求' }, + ) +const content = (p) => [{ type: 'text', text: p.text }, ...p.images] +test('聊天展示仅投影补充文字和注释卡片,模型仍收到完整截图、DOM、样式和位置', async () => { + const p = packet(), + r = annotationTranscript(content(p)) + assert.equal(r.userText, '这是补充要求') + assert.equal(r.notes[0].comment, note.comment) + assert.equal(r.notes[0].element.html, capture.element.html) + assert.deepEqual(r.notes[0].element.styles, capture.element.styles) + assert.equal(r.notes[0].element.source, capture.element.source) + assert.equal(r.images[0].data, 'YWJj') + assert.deepEqual(r.content, [{ type: 'text', text: '这是补充要求' }]) + let echo, wire + await deliverPacket( + { + beginSubmission: (e) => ((echo = e), { requestId: 'r' }), + prompt: async (c) => ( + (wire = c), + { ok: true, value: { accepted: true } } + ), + }, + p, + ) + assert.doesNotMatch(echo.text, /DOM|selector|src\/app/) + assert.match(echo.text, /1 条网页注释/) + assert.equal(wire[0].text, p.text) + assert.equal(wire[1].data, 'YWJj') + assert.equal( + annotationTranscript(wire).notes[0].element.html, + capture.element.html, + ) +}) +test('用户额外图片及文件仍走原生展示,截图与编号按顺序对应', () => { + const p = packet(), + extraImage = { type: 'image', attachment: { id: 'extra' } }, + file = { type: 'file', attachment: { name: '文档.pdf' } } + const r = annotationTranscript([...content(p), extraImage, file]) + assert.equal(r.images.length, 1) + assert.deepEqual(r.content.slice(1), [extraImage, file]) +}) +test('普通用户消息、损坏元数据和缺少截图时保留原生渲染,不能吞文字', () => { + assert.equal( + annotationTranscript([{ type: 'text', text: '普通消息
test
' }]), + null, + ) + assert.equal( + annotationTranscript([{ type: 'text', text: packet().text }]), + null, + ) + assert.equal( + annotationTranscript([ + { + type: 'text', + text: + ANNOTATION_INSTRUCTIONS + + '\nbad', + }, + { type: 'image' }, + ]), + null, + ) + assert.equal( + annotationTranscript([ + { + type: 'text', + text: serializeAnnotations([{ url: 'x', comment: 12, element: {} }]), + }, + { type: 'image' }, + ]), + null, + ) +}) +test('特殊标记、HTML 和多行批注可无损往返,不把页面内容作为 HTML 执行', () => { + const n = { + url: 'https://example.com', + comment: '\n
要求
', + element: capture.element, + } + const text = serializeAnnotations([n]) + assert.equal((text.match(/<\/dsh_browser_annotations>/g) || []).length, 1) + assert.equal( + annotationTranscript([{ type: 'text', text }, { type: 'image' }]).notes[0] + .comment, + n.comment, + ) +}) +test('旧版本已发送记录可折叠成注释卡片,并保留完整详情供审查', () => { + const text = + '用户补充要求:\n旧补充\n\n' + + ANNOTATION_INSTRUCTIONS + + '\n\n批注 1 · 对应图片 网页批注-1.jpg\n页面:https://example.com/\n用户要求:旧要求\n第二行\n目标:button · 提交\n定位:#submit(唯一性校验:唯一命中)\n视口坐标:{}\nDOM 引用:\n样式引用:{}' + const r = annotationTranscript([ + { type: 'text', text }, + { type: 'image', attachment: { id: 'screenshot' } }, + ]) + assert.equal(r.userText, '旧补充') + assert.equal(r.notes[0].comment, '旧要求\n第二行') + assert.match(r.notes[0].legacyContext, /