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-validation.md b/docs/browser-annotations-validation.md new file mode 100644 index 000000000..9206b4e31 --- /dev/null +++ b/docs/browser-annotations-validation.md @@ -0,0 +1,50 @@ +# Browser annotation validation on current Desktop + +Date: 2026-10-26 (macOS local date). Desktop 0.12.1, Harness 0.2.1-rc.2, Electron 43, Apple Silicon. Testing uses an independent development profile, a local HTML fixture and a deterministic local model endpoint. Production profiles, real provider credentials and real conversations are not used. + +## Decisions and fixes + +- The normal generation installer rejected the original private Puppeteer dependency graph: a subpath-only package did not resolve at its root and a dependency named `buffer` resolved as a Node builtin. The backend now bundles its private graph into `lib/index.cjs`. React remains host-owned. The generated archive includes third-party licenses and has no runtime dependencies. The archive smoke test imports it outside the checkout and launches real Chrome through the host route. +- The current native chat renderer did not display the earlier annotation footer projection. Sent messages now retain a readable native summary and an independent annotation review chip. Screenshot review delegates to the host's authorized user-content factory and image loader, rather than the removed `renderMessageImages` prop. Durable/model contents remain unchanged. +- Current file intake inserts native reference chips. The plugin now calls the session's public input-trigger owner codec for those references; it preserves the original editor snapshot for retirement and fails before admission if a reference is invalid or its owner fails. Claimed commands retain the native input flow. +- An explicit negative admission permits rebuilding a packet after the user repairs an attachment. A transport failure retains the exact request identity because admission may already have happened. An acknowledgement failure after admission cannot send the same message again. +- Successful combined-send testing exposed a reversed attachment-retirement contract: the host prune operation accepts the IDs to keep. Version 0.4.2 removes only submitted IDs and keeps attachments added while sending. Regression tests model the native keep-only behavior and protect concurrent draft edits. +- The plugin does not override Cmd/Ctrl+Shift+B, which current Desktop already assigns to the right sidebar. Browser entry stays in the native new-tab chooser. +- Build paths use file-URL conversion for Windows compatibility. Browser-process tests run sequentially to avoid competing launches. The distribution smoke test requires no registry/network install because all private runtime code is in the archive. + +## Observed desktop interaction + +These are actual Electron interactions, not screenshots of a separate prototype or source-string checks. + +| Case | Observed result | +| --- | --- | +| Normal profile generation loading | Bundled plugin passed the generation closure check and loaded in a normal Desktop launch. The native generation backend installed successive archives through 0.4.2. The final running generation was checked against the built client bytes and exercised after restart. | +| Sidebar entry and generic new tab | The native right-sidebar chooser offers Browser, workspace files and Terminal. Clicking + opens that chooser. | +| Native browser tabs | Opened two independent local pages, switched/closed the second tab and retained the first page. There is one native tab row. | +| Element annotation | Selecting the paragraph produced an input bubble beside its outline; Enter saved the comment and screenshot. | +| Region annotation | Dragging a region produced a `region` capture containing the matching page content and saved it to the composer. | +| Hold Space | With pointer intent on the page, held Space while clicking the counter: it changed from 0 to 1 without adding a note. After release, clicking the heading opened the annotation editor again. | +| Composer review | The annotation chip opened a floating popover with the correct selection screenshot and comment, without expanding a review pane. | +| Annotation-only send | A single Send action admitted the packet. The local model transport received actual image blocks, comment, selector `#copy`, DOM/style evidence, rectangle and viewport. There was no mandatory review/confirmation step. | +| Sent-message review | After the renderer fix and restart, the transcript showed a readable comment summary. The sent annotation chip opened a popover with its authorized screenshot and collapsed element details. | +| Combined send and retirement | On 0.4.2, submitted a plain draft, native file reference, ordinary JPEG and saved annotation in one Enter action. The local model received both new images and the serialized file reference. On completion the draft, annotation and submitted image rail cleared, and native Send became disabled. Concurrent new attachment preservation is covered by the regression tests. | +| Running queue | Held a real model response, saved a note during the run and sent through the accessory. Native Stop stayed available. The queue admitted one note, then produced exactly one next turn after the held run completed. | +| Native Stop | Stopped a held run with the native primary button. The transcript reported Stopped and no annotation submit displaced it. | +| Theme and width | Inspected dark theme at a 1040 × 780 window, including the anchored editor, toolbar and screenshot review. Light theme was exercised in earlier interactions. | +| Locale | Chinese UI was exercised first. Set the isolated profile locale to English and restarted; browser navigation, annotation controls and review popovers displayed English. The native language picker did not expand during the UI attempt, so that picker itself is not claimed as verified. | +| Disable and re-enable | Used the native plugin manager toggle without restart. Disable removed Browser from the chooser and the annotation projections; a normal native message still completed. Re-enable restored one Browser entry, the prior page and readable history. A new note saved once and sent once after reloading. When disabled, durable annotation payloads remain visible through the native fallback renderer. | +| Failed input preservation | A mislabelled screenshot fixture was rejected by DSH's MIME validation; text, reference, image and saved annotation remained available. The explicit-rejection retry fix is additionally covered by tests. | + +The local model endpoint verifies transport contents and admission/queue mechanics; it does not prove a commercial model's visual reasoning quality. + +## Automated validation and limits + +- Plugin regression: the final run passed all 82 tests, including real Chrome reconnection/lifecycle, archive runtime, reference serialization, readable message projection, admission retry identity, stale locks and installation cleanup. The 0.4.2 run includes standalone archive launch and license verification, completed in 43.5 seconds, with zero skipped/cancelled tests. +- Clean root `npm ci`/postinstall patch replay, typecheck and Desktop build passed before desktop testing. Final typecheck and Electron bundling passed after the fixes. The repeated full `npm run build` timed out in Office template verification; it is not reported as a final all-green Desktop build. The unchanged, previously verified template cache was restored for the isolated development launch. The only host-patch addition remains the native primary-button `send`/`stop` marker. +- The full root suite was executed without skipping tests: 2203 passed and one DOCX fidelity test exceeded its 60-second deadline in the two-worker run. That test had passed alone in 26.8 seconds earlier; a later isolated rerun again timed out at 60 seconds. Concurrent unrelated integration workloads were observed, but this does not by itself prove each timeout is benign. A serial full run passed 2195 and timed out in nine tests across four Office suites. A diagnostic run outside iCloud reproduced broader integration/hook timeouts and was stopped after that repeated result; it is not counted as a completed full run. These runs are not reported as an all-green full regression; no Office assertion/deadline was weakened to obtain a pass. +- After unlock, `npm test -- --maxWorkers=2` stopped in its Office template pretest. Restoring the unchanged earlier template cache allowed a direct, complete `npx vitest run --maxWorkers=2`: **216/219 files passed; 2186/2204 tests passed, 18 failed**, in 957.2 seconds. Failures were confined to `office-runtime.test.mjs` (3), `ppt-import-memory.test.mjs` (4), and `ppt-personal-templates.test.mjs` (11): timeouts plus runtime-ready and import-progress assertions. This direct runner result does not turn the failed npm pretest into a pass. Those Office sources/tests are unchanged by this contribution; a matching upstream baseline has not been established, so the failures are not certified as pre-existing. The run completed without skips or cancelled tests. +- Cross-platform CI is defined for the optional package. No remote check result was reported on the PR at the last inspection; Windows/Linux desktop behavior is not locally verified. + +The unlocked desktop checks above complete the macOS interaction pass for version 0.4.2. The optional plugin is ready for review with the root Office regression/build failures disclosed; this is not an all-green repository or cross-platform release certification. + +Document-preview annotation UI for PDF/Word/Markdown is outside this PR because current main does not expose the incoming prototype's legacy preview contract. No file-preview or side-chat/session-mail delivery is claimed here. diff --git a/docs/browser-annotations.md b/docs/browser-annotations.md new file mode 100644 index 000000000..4701c7e63 --- /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. See [the current-main validation record](browser-annotations-validation.md) for real Desktop observations, fixes and the validation limits. Incoming Desktop 0.12.0 prototype evidence is not substituted for these checks. The contribution remains opt-in; cross-platform desktop checks are separate from local macOS validation. 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..c1efa620f --- /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. Transport retries reuse the request ID; an explicit admission rejection permits a fresh packet after repairing attachments. Concurrent edits remain pending. Inline file/session references use their native owner codec and fail without consuming the draft when that owner is unavailable. Claimed slash commands remain with the native input flow. + +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 a self-contained CommonJS backend and the client registration wrapper. Private Puppeteer dependencies are bundled with `lib/THIRD_PARTY_LICENSES.txt`; React stays external and is supplied by the host. The extracted archive can launch its backend without installing development dependencies. `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. The host owns its sidebar keyboard shortcuts; the plugin does not override them. 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 an unmigrated development link 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. Once Desktop migrates the link to an immutable generation, use Desktop's plugin manager to update, disable or remove it; this development helper deliberately refuses replacing a managed generation link. + +## Compatibility seam and review boundary + +The incoming implementation was exercised in Desktop 0.12.0 / Harness 0.2.0-rc.2. Version 0.4.2 targets the repository's pinned Harness 0.2.1-rc.2. See the current-main validation record in [the validation record](../../docs/browser-annotations-validation.md) for observed desktop behavior and validation limits; validation of the incoming prototype is not a substitute for that record. + +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 desktop interaction remains a release-validation item; local macOS evidence is recorded separately. diff --git a/packages/dsh-page-review/build.mjs b/packages/dsh-page-review/build.mjs new file mode 100644 index 000000000..17b4529fa --- /dev/null +++ b/packages/dsh-page-review/build.mjs @@ -0,0 +1,83 @@ +import { build } from 'esbuild' +import { mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = new URL('./', import.meta.url) +await mkdir(new URL('lib/', root), { recursive: true }) +// The Desktop generation checker resolves every runtime dependency root. Some +// Puppeteer transitives intentionally have only subpath exports or use a Node +// builtin name. Bundle that private graph so installation has no phantom peers. +// CJS also gives Puppeteer's optional Node requires their real runtime loader. +const host = await build({ + entryPoints: [fileURLToPath(new URL('src/host.mjs', root))], + bundle: true, + write: false, + metafile: true, + platform: 'node', + format: 'cjs', + target: 'node22', + define: { 'import.meta.url': '__bundle_url__' }, + banner: { + js: "const __bundle_url__ = require('node:url').pathToFileURL(__filename).href;", + }, + legalComments: 'inline', +}) +await writeFile(new URL('lib/index.cjs', root), host.outputFiles[0].text) +await rm(new URL('lib/index.js', root), { force: true }) +// Preserve the license and notice of every package contributing bundled code. +const owners = new Set() +const inputDirectories = new Set( + Object.keys(host.metafile.inputs) + .filter((input) => /node_modules[\\/]/.test(input)) + .map((input) => dirname(resolve(input))), +) +for (let directory of inputDirectories) { + while (directory !== dirname(directory)) { + try { + const manifest = JSON.parse( + await readFile(join(directory, 'package.json'), 'utf8'), + ) + // ESM/CJS subdirectories can contain a type-only package.json. Their + // license belongs to the named package above that module boundary. + if (manifest.name && manifest.version) { + owners.add(directory) + break + } + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + directory = dirname(directory) + } +} +const notices = [] +for (const owner of [...owners].sort()) { + const pkg = JSON.parse(await readFile(join(owner, 'package.json'), 'utf8')) + notices.push( + `\n===== ${pkg.name}@${pkg.version} (${pkg.license || 'see notice'}) =====\n`, + ) + for (const file of (await readdir(owner)) + .filter((name) => /^(licen[cs]e|notice|copying)([._-]|$)/i.test(name)) + .sort()) { + notices.push(`${file}\n${await readFile(join(owner, file), 'utf8')}\n`) + } +} +await writeFile( + new URL('lib/THIRD_PARTY_LICENSES.txt', root), + notices.join('\n'), +) +const result = await build({ + entryPoints: [fileURLToPath(new URL('src/client.jsx', root))], + 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..767e2264d --- /dev/null +++ b/packages/dsh-page-review/package-lock.json @@ -0,0 +1,2443 @@ +{ + "name": "dsh-page-review", + "version": "0.4.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dsh-page-review", + "version": "0.4.2", + "license": "MIT", + "devDependencies": { + "esbuild": "0.25.12", + "jsdom": "26.0.0", + "prettier": "3.6.2", + "puppeteer-core": "23.11.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "tar": "7.5.22" + }, + "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/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz", + "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "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==", + "dev": true, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "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==", + "dev": true, + "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/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-bidi": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz", + "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "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==", + "dev": true, + "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==", + "dev": true, + "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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "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/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "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/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "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==", + "dev": true, + "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/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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..7296fb1f3 --- /dev/null +++ b/packages/dsh-page-review/package.json @@ -0,0 +1,75 @@ +{ + "name": "dsh-page-review", + "version": "0.4.2", + "private": true, + "description": "Optional browser annotations with screenshots, element context and chat handoff.", + "type": "module", + "main": "lib/index.cjs", + "exports": { + ".": "./lib/index.cjs", + "./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-ui-input-trigger", + "@deepseek-ai/dsh-client-resources", + "@deepseek-ai/dsh-client-locale" + ] + } + }, + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "node build.mjs", + "test": "node --test --test-concurrency=1 tests/*.test.mjs", + "prepack": "npm run build", + "format": "prettier --write src tests build.mjs install-local.mjs" + }, + "license": "MIT", + "files": [ + "lib", + "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", + "puppeteer-core": "23.11.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "tar": "7.5.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 + } + } +} 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..e727183a1 --- /dev/null +++ b/packages/dsh-page-review/src/client.jsx @@ -0,0 +1,2031 @@ +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, + serializeComposerReferences, + 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', + 'inputTriggers', + 'resources', + 'locale', +] +const stores = new Map() +let sessionsService, + conversationService, + sidebarService, + openBrowserLink, + inputTriggersService +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), + initial = captureComposer(shell.snapshot), + scope = + sessionsService.binding(sid)?.ctx || sessionsService.scope?.(sid), + capture = await serializeComposerReferences( + initial, + initial.references.length + ? inputTriggersService.sessionOf(scope) + : null, + AbortSignal.timeout(35000), + ) + 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/message.test.mjs b/packages/dsh-page-review/tests/message.test.mjs new file mode 100644 index 000000000..bd2bd1828 --- /dev/null +++ b/packages/dsh-page-review/tests/message.test.mjs @@ -0,0 +1,112 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { serializeAnnotations } from '../src/transcript.mjs' + +const compiled = await build({ + entryPoints: [fileURLToPath(new URL('../src/message.jsx', import.meta.url))], + bundle: true, + write: false, + platform: 'node', + format: 'cjs', + external: ['react'], +}) +const module = { exports: {} } +runInNewContext(compiled.outputFiles[0].text, { + module, + exports: module.exports, + require: createRequire(import.meta.url), +}) +const { annotationMessageRenderer } = module.exports + +test('sent annotations remain readable without a native footer; screenshots use the authorized content factory', () => { + const attachment = { id: 'screenshot-ref' }, + extra = { type: 'file', attachment: { name: 'qa.txt' } } + const original = { + data: { + content: [ + { + type: 'text', + text: + 'QA_DRAFT\n\n' + + serializeAnnotations([ + { + url: 'https://example.com/', + comment: 'Shorten the headline', + element: { + tag: 'p', + selector: '#copy', + html: '

Original

', + }, + }, + ]), + }, + { type: 'image', attachment }, + extra, + ], + }, + } + let nativeContent, imageFactory, imageLoader + const loadImage = () => { + throw Error('The plugin must delegate attachment loading to the host') + } + // A native renderer which does not consume messageFooter must still show the + // comment. This caught a blank transcript in the actual Desktop build. + const Native = ({ node }) => { + nativeContent = node.data.content + return React.createElement( + 'article', + null, + nativeContent + .filter((b) => b.type === 'text') + .map((b) => b.text) + .join(''), + ) + } + const Wrapped = annotationMessageRenderer(Native) + const html = renderToStaticMarkup( + React.createElement(Wrapped, { + node: original, + loadImage, + renderFactorySlot(name, props) { + imageFactory = name + imageLoader = props.loadImage + assert.equal(props.content.attachments[0].image.attachment, attachment) + return React.createElement('img', { alt: 'Authorized screenshot' }) + }, + }), + ) + assert.match(html, /QA_DRAFT/) + assert.match(html, /Shorten the headline/) + assert.match(html, /Authorized screenshot/) + assert.match(html, /pr-message-annotations/) + assert.equal(imageFactory, 'conversation.chat.user.content') + assert.equal(imageLoader, loadImage) + assert.equal(nativeContent[1], extra) + assert.equal(nativeContent[0].text.includes('dsh_browser_annotations'), false) + assert.equal(nativeContent[0].text.includes('

'), false) + assert.equal(original.data.content.length, 3) + assert.match(original.data.content[0].text, /dsh_browser_annotations/) +}) + +test('ordinary messages and their native footer pass through intact', () => { + const node = { + data: { content: [{ type: 'text', text: 'Ordinary message' }] }, + } + let received + const Wrapped = annotationMessageRenderer((props) => { + received = props + return null + }) + const footer = React.createElement('span', null, 'Native review') + renderToStaticMarkup( + React.createElement(Wrapped, { node, messageFooter: footer }), + ) + assert.equal(received.node, node) + assert.equal(received.messageFooter, footer) +}) diff --git a/packages/dsh-page-review/tests/package.test.mjs b/packages/dsh-page-review/tests/package.test.mjs new file mode 100644 index 000000000..ab08d7cfd --- /dev/null +++ b/packages/dsh-page-review/tests/package.test.mjs @@ -0,0 +1,134 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { runInNewContext } from 'node:vm' +import { x } from 'tar' +import { createServer } from 'node:http' +import { Readable } from 'node:stream' +const exec = promisify(execFile) + +test('real packed distribution imports its host and registers the external-React client', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-review-package-')) + const previousHome = process.env.DSH_HOME, + disposers = [] + let server + try { + // The npm CLI that started npm test is portable across cmd.exe and POSIX. + const npm = process.env.npm_execpath + assert.ok(npm, 'run distribution tests through npm test') + const { stdout } = await exec( + process.execPath, + [npm, 'pack', '--json', '--pack-destination', root], + { + cwd: fileURLToPath(new URL('../', import.meta.url)), + maxBuffer: 2 * 1024 * 1024, + }, + ) + const start = stdout.indexOf('[\n'), + pack = JSON.parse(stdout.slice(start))[0] + assert.equal(pack.name, 'dsh-page-review') + assert.equal( + pack.files.some((f) => /^(node_modules|backups|tests)\//.test(f.path)), + false, + ) + await x({ file: join(root, pack.filename), cwd: root }) + // The backend has no runtime dependencies. Load the extracted archive + // directly, with no npm/network step and no adjacent development tree. + const manifest = JSON.parse( + await readFile(join(root, 'package/package.json'), 'utf8'), + ) + assert.equal(Object.keys(manifest.dependencies || {}).length, 0) + assert.ok(pack.files.some((f) => f.path === 'lib/THIRD_PARTY_LICENSES.txt')) + const notices = await readFile( + join(root, 'package/lib/THIRD_PARTY_LICENSES.txt'), + 'utf8', + ) + assert.match(notices, /puppeteer-core@23\.11\.1/) + assert.match(notices, /@puppeteer\/browsers@/) + assert.doesNotMatch(notices, /undefined@undefined/) + const host = await import( + pathToFileURL(join(root, 'package/lib/index.cjs')) + ) + assert.equal(host.name, 'dsh-page-review') + assert.equal(typeof host.apply, 'function') + const fixture = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }) + res.end('

Distribution browser smoke

') + }) + server = fixture + await new Promise((r) => fixture.listen(0, '127.0.0.1', r)) + process.env.DSH_HOME = join(root, 'home') + let route + host.apply({ + webServer: { + register: (value) => { + route = value + return () => {} + }, + }, + effect: (fn) => { + const off = fn() + if (off) disposers.push(off) + }, + }) + const req = Readable.from([ + Buffer.from( + JSON.stringify({ + session: 'package-smoke', + kind: 'create', + url: 'http://127.0.0.1:' + fixture.address().port, + }), + ), + ]) + Object.assign(req, { + method: 'POST', + url: '/api/page-review/browser', + socket: { remoteAddress: '127.0.0.1' }, + headers: { host: '127.0.0.1:9', origin: 'http://127.0.0.1:9' }, + }) + let status, body + await route.handler(req, { + writeHead: (code) => (status = code), + end: (value) => (body = JSON.parse(value)), + }) + assert.equal(status, 200, JSON.stringify(body)) + assert.ok(body.ok) + assert.equal(body.value.tabs.length, 1) + let registration + runInNewContext( + await readFile(join(root, 'package/lib/client.js'), 'utf8'), + { + window: { + __ModuleLoader__: { load: (value) => (registration = value) }, + }, + Map, + Set, + console, + }, + ) + assert.equal(registration.id, 'dsh-page-review') + const required = [] + const client = registration.factory((name) => { + required.push(name) + if (name === 'react' || name === 'react-dom') return {} + throw Error('unexpected runtime dependency ' + name) + }) + assert.equal(typeof client.apply, 'function') + assert.ok(client.inject.includes('locale')) + assert.ok( + required.includes('react'), + 'React must be supplied by the host, not bundled', + ) + } finally { + for (const off of disposers.reverse()) await off() + if (server) await new Promise((r) => server.close(r)) + if (previousHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousHome + await rm(root, { recursive: true, force: true, maxRetries: 3 }) + } +}) 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..7c9ca951b --- /dev/null +++ b/packages/dsh-page-review/tests/review.test.mjs @@ -0,0 +1,251 @@ +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, + {}, + ), + (error) => + error.admissionRejected === true && /未确认接收/.test(error.message), + ) +}) +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..1e741352a --- /dev/null +++ b/packages/dsh-page-review/tests/submission.test.mjs @@ -0,0 +1,188 @@ +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, + attachmentIds: ['image-a', 'image-b'], + } + 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-b']]) +}) + +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]) +}) + +test('explicit admission rejection permits repaired attachments; transport failures retain the attempt', async () => { + let prepared = 0, + calls = 0, + received + const sender = createDirectSender({ + prepare: async () => ({ version: ++prepared }), + deliver: async (packet) => { + calls++ + if (calls === 1) + throw Object.assign(Error('Invalid image bytes'), { + admissionRejected: true, + }) + received = packet.version + }, + retire() {}, + ack: async () => ({}), + }) + await assert.rejects(sender.send(), /Invalid image/) + await sender.send() + assert.equal(prepared, 2) + assert.equal(received, 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, /