diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 008b90d..871f50c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,16 +4,114 @@ on: push: branches: - main + - dev + pull_request: + branches: + - main + - dev permissions: contents: write issues: write pull-requests: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: + verify: + name: Verify (lint, unit, build, e2e) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + # Required because src/stores/profile.ts imports types from + # ../../../dashboard-app/frontend/types/database.type — see CLAUDE.md. + - name: Checkout sibling dashboard-app + run: git clone --depth 1 https://github.com/codebridger/subturtle-dashboard-app.git ../dashboard-app + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: | + playwright-${{ runner.os }}- + + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install chromium --with-deps + + - name: Install Playwright system deps (cache hit path) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + - name: Type check + run: yarn typecheck + + - name: Unit tests (Vitest) + run: yarn test + + # dotenv-webpack is configured with `safe: true`, so the build needs + # every key in .env.example to exist at build time. We write + # non-empty placeholders rather than copying the empty .env.example + # — `mixpanel.init("")` throws synchronously during the content-script + # import chain, which silently halts every Vue mount and was the root + # cause of e2e tests failing on `#subturtle-{nibble,console-crane}-root` + # never appearing. SUBTURTLE_API_URL points at the local fixtures + # server so any auth/translate calls 404 instead of escaping to the + # real backend. + - name: Stub .env.production for verify build + run: | + cat > .env.production <<'EOF' + MIXPANEL_PROJECT_TOKEN=ci_e2e_stub_token + MIXPANEL_API_HOST=http://localhost:4173/_mixpanel_stub + GOOGLE_TRANSLATE_KEY=ci_e2e_stub_key + GOOGLE_TRANSLATE_PROXY_URL=http://localhost:4173/_translate_proxy_stub + UNINSTALL_FORM_URL=http://localhost:4173/_uninstall_stub + SUBTURTLE_API_URL=http://localhost:4173 + SUBTURTLE_DASHBOARD_URL=http://localhost:4173/_dashboard_stub + GOOGLE_OAUTH_CLIENT_ID=ci_e2e_stub_oauth_client + EOF + + - name: Build extension + run: yarn build + + - name: E2E tests (Playwright) + run: yarn test:e2e + + - name: Upload Playwright report + # Run on both success and failure (anything except job cancel) so + # the HTML report is downloadable for green runs too. The + # hashFiles guard skips silently when typecheck or unit tests + # failed before Playwright produced any output. + if: ${{ !cancelled() && hashFiles('playwright-report/**') != '' }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + release: name: Release + needs: verify + if: github.event_name == 'push' runs-on: ubuntu-latest + environment: ${{ github.ref_name == 'main' && 'prod' || 'dev' }} steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 9cb5671..497509d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ dist static/key-file.json *.zip .npmrc +/.claude +/playwright-report +/test-results \ No newline at end of file diff --git a/.releaserc.json b/.releaserc.json deleted file mode 100644 index d0ff26b..0000000 --- a/.releaserc.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "branches": ["main"], - "plugins": [ - "@semantic-release/commit-analyzer", - "@semantic-release/release-notes-generator", - [ - "@semantic-release/npm", - { - "npmPublish": false - } - ], - [ - "@semantic-release/exec", - { - "prepareCmd": "node scripts/sync-manifest-version.mjs ${nextRelease.version}" - } - ], - [ - "@semantic-release/git", - { - "assets": ["package.json", "static/manifest.json"], - "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" - } - ], - [ - "@semantic-release/github", - { - "assets": [ - { - "path": "subturtle.zip", - "name": "subturtle-v${nextRelease.version}.zip", - "label": "Subturtle Chrome extension (v${nextRelease.version})" - } - ] - } - ] - ] -} diff --git a/CHANGELOG-DEV.md b/CHANGELOG-DEV.md new file mode 100644 index 0000000..fa4fb7d --- /dev/null +++ b/CHANGELOG-DEV.md @@ -0,0 +1,18 @@ +# [1.11.0-dev.2](https://github.com/codebridger/subturtle-extension-apps/compare/v1.11.0-dev.1...v1.11.0-dev.2) (2026-05-03) + + +### Features + +* add loading state and section divider to popup translate ([9c89f83](https://github.com/codebridger/subturtle-extension-apps/commit/9c89f83557f899bc1465de7625d0149489c73dda)), closes [#86exfjner](https://github.com/codebridger/subturtle-extension-apps/issues/86exfjner) +* add translate input on popup home view ([efb435c](https://github.com/codebridger/subturtle-extension-apps/commit/efb435cbbbea03598fefe6a6bff0c8fd989caaa8)), closes [#86exfjner](https://github.com/codebridger/subturtle-extension-apps/issues/86exfjner) +* refresh popup help view to cover web text and quick translate [#86](https://github.com/codebridger/subturtle-extension-apps/issues/86)exfjner ([a0968ec](https://github.com/codebridger/subturtle-extension-apps/commit/a0968ec2ba5c551d94151075ce08217675cedbd9)), closes [#86exfjner](https://github.com/codebridger/subturtle-extension-apps/issues/86exfjner) + +# [1.11.0-dev.1](https://github.com/codebridger/subturtle-extension-apps/compare/v1.10.1...v1.11.0-dev.1) (2026-05-03) + + +### Features + +* add bootstrap loader to popup for improved initial load experience ([02c59f8](https://github.com/codebridger/subturtle-extension-apps/commit/02c59f89232f44c4ef0dd3a4dbbef07f37469e80)) +* add prerelease support for dev branch with environment-specific changelogs and configs ([24f087b](https://github.com/codebridger/subturtle-extension-apps/commit/24f087b5b8e6cc4c7c53ed1f2e2f72b974d01f6d)) + +# Dev Changelog diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/CLAUDE.md b/CLAUDE.md index c3b4077..f28b844 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,25 +5,31 @@ Operating manual for working inside this repo. For product overview / supported ## Quick start ```bash -npm install -npm run dev # webpack --watch, writes dist/ -npm run build # NODE_ENV=production webpack --mode=production +yarn install +yarn dev # webpack --watch, writes dist/ +yarn build # NODE_ENV=production webpack --mode=production + +yarn test # Vitest one-shot (unit + component) +yarn test:watch # Vitest watch mode +yarn test:e2e # Playwright E2E against the loaded extension (requires dist/) +yarn typecheck # tsc --noEmit via the upstream-error filter ``` Load `dist/` as an unpacked extension at `chrome://extensions`. There is no separate dev server — the bundler writes straight to `dist/`, and Chrome reloads when you click the reload button on the extension card. -## Three content surfaces (+ popup, background) +## Bundles (+ popup, background) | Bundle | Entry | Runs on | Purpose | | --- | --- | --- | --- | | `main.js` | [src/main.ts](src/main.ts) | YouTube `/watch`, Netflix | Subtitle phrase collector — wraps caption words in `` spans, hover/anchor selection. | | `nibble.js` | [src/nibble.ts](src/nibble.ts) | `` | Web text phrase collector — native `Selection` → floating Subturtle icon → translation card. **Does not mutate page DOM.** | -| `popup.js` | [src/popup.ts](src/popup.ts) | Toolbar popup | Settings, language, dashboard link, per-site Nibble toggle. | +| `console-crane.js` | [src/console-crane.ts](src/console-crane.ts) | `` | The modal app (word-detail, settings, save flow). Owns its own Vue app + Pinia store + router. Feature bundles drive it via the [bridge](src/common/services/console-crane-bridge.ts). | +| `popup.js` | [src/popup.ts](src/popup.ts) | Toolbar popup | Ad-hoc text translation (input + detailed result), settings, language, dashboard link, per-site Nibble toggle. Reuses console-crane's `WordDetailModule` for the result panel — see [Shared APIs § WordDetailModule](#worddetailmodule-detailed-translation-panel) for the cross-bundle reuse rules. | | `background.js` | [src/background.ts](src/background.ts) | Service worker | OAuth, token storage, settings persistence to `chrome.storage.local`, broadcast `SYNC_SETTINGS` to tabs. | -Manifest content_scripts split is in [static/manifest.json](static/manifest.json). Each surface gets its own bundle — they share Vue components and Pinia stores via the source tree, but no two surfaces ever load the same compiled JS on the same page. +Manifest content_scripts split is in [static/manifest.json](static/manifest.json). On a YouTube `/watch` page all three content scripts run side-by-side in the same isolated world — `main.js`, `nibble.js`, and `console-crane.js` — so they coordinate through shared `chrome.storage` (settings) and `window` CustomEvents (the ConsoleCrane bridge). -ConsoleCrane (the modal app at [src/console-crane/](src/console-crane/)) is mounted by every surface — subtitle, nibble, and the popup share it through component registration in their respective entry files. +**ConsoleCrane is its own content script, not a component embedded in feature bundles.** There is exactly one ConsoleCrane instance per page regardless of which feature bundles are loaded. Feature bundles never `import` the ConsoleCrane component or its store directly; they call `emitOpen()` from [src/common/services/console-crane-bridge.ts](src/common/services/console-crane-bridge.ts) and listen to `onState()` for "is the modal open right now". See [Shared APIs § ConsoleCrane bridge](#consolecrane-bridge) below. ## Style isolation: the two non-negotiable rules @@ -35,6 +41,7 @@ Bootstraps that already get this right: - [src/subtitle/web_youtube/initializer.ts](src/subtitle/web_youtube/initializer.ts) — appends `
` - [src/subtitle/web_netflix/initializer.ts](src/subtitle/web_netflix/initializer.ts) — same pattern - [src/nibble/initializer.ts](src/nibble/initializer.ts) — `
` +- [src/console-crane/initializer.ts](src/console-crane/initializer.ts) — `
` When adding a new mount point, copy this pattern. The class also drives dark mode — see `applyThemeToDOM` in [src/common/store/settings.ts](src/common/store/settings.ts). @@ -55,18 +62,29 @@ Implications: ## Shared APIs -### ConsoleCrane modal +### ConsoleCrane bridge + +From any feature bundle (subtitle, nibble), open the modal by emitting a CustomEvent on `window`: ```ts -import { useConsoleCraneStore } from "@/console-crane/stores/console-crane"; -useConsoleCraneStore().toggleConsoleCrane( - "word-detail", // page: "empty" | "word-detail" | "settings" - { word: phrase, context: paragraphText }, // params (any object) - true // active: pass true to force-open (omit to toggle) -); +import { emitOpen } from "@/common/services/console-crane-bridge"; +emitOpen({ + page: "word-detail", // "empty" | "word-detail" | "settings" + params: { word: phrase, context: paragraphText }, + active: true, // pass true to force-open (omit to toggle) +}); ``` -Params are encoded into the route via `encodeRouteParams` in [src/console-crane/stores/console-crane.ts](src/console-crane/stores/console-crane.ts) — Unicode-safe (uses TextEncoder). Decode with `decodeRouteParams`. **Never use `window.btoa(JSON.stringify(...))` directly** — it throws `InvalidCharacterError` on non-Latin1 input (Persian, CJK, emoji, accented Latin). +The console-crane content script listens (`onOpen` in [src/console-crane.ts](src/console-crane.ts)) and calls `store.toggleConsoleCrane(...)` against its own Pinia store. Two complementary channels exist for state flowing the other way: + +- `onState((s) => ...)` — fires whenever `isActive` changes. Used by Nibble's [SelectionPopup](src/nibble/components/NibbleSurface.vue) to hide itself while the modal is open. +- `requestState()` — feature bundle asks console-crane to re-emit its current state. A freshly mounted listener calls this to sync up rather than waiting for the next change. + +**Inside the console-crane bundle itself**, code can keep using `useConsoleCraneStore()` directly — it's the same Vue app. Bridge events are only the cross-bundle path. + +Params are encoded into the route via `encodeRouteParams` from [src/console-crane/route-params.ts](src/console-crane/route-params.ts) — Unicode-safe (uses TextEncoder). Decode with `decodeRouteParams`. **Never use `window.btoa(JSON.stringify(...))` directly** — it throws `InvalidCharacterError` on non-Latin1 input (Persian, CJK, emoji, accented Latin). + +These helpers live in their own module (separate from the store) so consumers can import them without dragging in the console-crane router. Importing them from the store would close a circular ESM init when the importer isn't `console-crane.ts` itself (popup → WordDetailModule → store → router → WordDetailModule). Keep them in `route-params.ts`. ### Translation @@ -77,6 +95,25 @@ const text = await TranslateService.instance.fetchSimpleTranslation(phrase, cont 24-hour in-memory cache keyed on `(translationType, targetLanguage, phrase, context)`. `fetchDetailedTranslation` for the rich `LanguageLearningData` shape used by ConsoleCrane. +### WordDetailModule (detailed translation panel) + +[src/console-crane/modules/word-detail/index.vue](src/console-crane/modules/word-detail/index.vue) is the rich result panel — definition, phonetic, examples, related expressions, plus the bundle save UI from [SaveWordSectionV2](src/console-crane/components/SaveWordSectionV2.vue). It runs its own `fetchDetailedTranslation` call internally, so the caller just supplies inputs. + +It supports two mounting modes: + +- **Route-driven** (console-crane): mounted by the console-crane router; reads `{ word, context }` from the base64-encoded `:data` route param. This is what `emitOpen({ page: "word-detail", params })` ultimately drives. +- **Prop-driven** (popup, anywhere outside the console-crane router): pass `:word` and optional `:context` directly. When `word` is present it's preferred over the route param. + +Also emits `loading: boolean` mirroring its internal pending state — bind it on the parent (e.g. the popup's [TranslateCard](src/popup/components/TranslateCard.vue)) to reflect a button spinner. + +**Cross-bundle reuse caveat.** The "feature bundles never import the ConsoleCrane component or its store" rule is about the modal wrapper and `useConsoleCraneStore` — they're for opening the modal on a page that already has the ConsoleCrane content script. Reusing presentational sub-modules like `WordDetailModule` (and the things it transitively pulls in: `SaveWordSectionV2`, `SelectPhraseBundleV2`, `FreemiumLimitCounter`) **is fine** as long as: + +1. You're in a bundle that does NOT also load `console-crane.js` (today: only the popup qualifies — it's its own Chrome extension page, not a content script). +2. You drive the module via props, not by trying to inject route params it doesn't have. +3. The host app installs Pinia + the modular-rest auth plugin before mount (`addPlugins(app)` from [src/plugins/install.ts](src/plugins/install.ts)). + +If you ever need this from a content-script bundle that runs alongside ConsoleCrane, use the [bridge](#consolecrane-bridge) instead — don't double-mount the same component on the same page. + ### Settings store [src/common/store/settings.ts](src/common/store/settings.ts) — Pinia store, syncs through background via `SYNC_SETTINGS`. Holds: @@ -94,12 +131,12 @@ And the `SettingsObject` type in [src/common/types/messaging.ts](src/common/type ### Marker store (subtitle surfaces only) -[src/stores/marker.ts](src/stores/marker.ts) — central authority for word marking, hover, anchors, auto-clear timers. Used by `` / `` / `` / `` in [src/subtitle/components/specific/](src/subtitle/components/specific/). **Nibble does not use the marker store** — it gets `text` + `context` directly from `window.getSelection()` and passes them straight to ConsoleCrane. +[src/stores/marker.ts](src/stores/marker.ts) — central authority for word marking, hover, anchors, auto-clear timers. Used by `` / `` / `` / `` in [src/subtitle/components/specific/](src/subtitle/components/specific/). **Nibble does not use the marker store** — it reads `text` + `context` from `window.getSelection()` and forwards them through the ConsoleCrane bridge. ## Gotchas - **Pinia install order in entry scripts.** `useSettingsStore()` requires Pinia. Always run `addPlugins(app)` (see [src/plugins/install.ts](src/plugins/install.ts)) before any `useXxxStore()` call. The Nibble entry initializes Pinia, then settings, then gates the per-domain check, then mounts. -- **Nibble root must NOT have `pointer-events: none`.** It's a 0×0 fixed element so it can't intercept clicks anyway, but `pointer-events: none` cascades into ConsoleCrane and swallows all modal clicks. Leave the root unspecified for pointer events. +- **Nibble and ConsoleCrane roots must NOT have `pointer-events: none`.** Both are 0×0 fixed elements so they can't intercept clicks anyway, but `pointer-events: none` cascades into the modal and swallows all clicks. Leave the root unspecified for pointer events. - **Selection popup must `@mousedown.prevent.stop`.** Otherwise clicking the popup deselects the page text, the composable detects the empty selection, and the popup unmounts mid-click. - **The mount root in Nibble must not block the page.** Set `width: 0; height: 0; position: fixed; top: 0; left: 0`. Children use their own `position: fixed` to position themselves relative to the viewport. - **Theme dark class lives on `.subturtle-scope`, not ``.** Tailwind's `dark:` rules are rewritten by `postcss-prefix-selector` to `.subturtle-scope.dark ...` — so the same element must carry both classes. The settings store handles this and a `MutationObserver` keeps Vue Teleport subtrees in sync. @@ -117,7 +154,7 @@ Most additions go in [src/nibble/](src/nibble/) — composables, components, pop ### A new ConsoleCrane page -Add the route to [src/console-crane/router.ts](src/console-crane/router.ts), add the page name to the `ConsolePage` type in [src/console-crane/types.ts](src/console-crane/types.ts), and call `toggleConsoleCrane(, params, true)` from wherever you trigger it. Params are Unicode-safely encoded for free. +Add the route to [src/console-crane/router.ts](src/console-crane/router.ts), add the page name to the `ConsolePage` type in [src/console-crane/types.ts](src/console-crane/types.ts), and trigger it via `emitOpen({ page: "", params, active: true })` from any feature bundle (or `useConsoleCraneStore().toggleConsoleCrane(...)` if you're calling from inside the console-crane bundle itself). Params are Unicode-safely encoded for free. ### A new content script entry @@ -125,20 +162,23 @@ Add the route to [src/console-crane/router.ts](src/console-crane/router.ts), add 2. Add a `content_scripts` block in [static/manifest.json](static/manifest.json) with the right URL match. 3. The entry script must mount its Vue root inside a `.subturtle-scope`-classed element (see Style isolation rule 1). 4. Run `addPlugins(app)` before any store usage. +5. To open the modal from the new bundle, use the [ConsoleCrane bridge](#consolecrane-bridge) — never import the ConsoleCrane component or store directly from another bundle. ## Release pipeline -Releases are automated by [.github/workflows/release.yml](.github/workflows/release.yml) running [`semantic-release`](https://semantic-release.gitbook.io/) on every push to `main`. The pipeline is split into visible workflow steps rather than hidden inside a single `yarn release` call — read the workflow file end-to-end before changing it. +Releases are automated by [.github/workflows/release.yml](.github/workflows/release.yml) running [`semantic-release`](https://semantic-release.gitbook.io/) on every push to `main` (stable channel) **or `dev` (prerelease channel)**. The pipeline is split into visible workflow steps rather than hidden inside a single `yarn release` call — read the workflow file end-to-end before changing it. + +A top-level `concurrency:` block keys on `github.ref`, so two pushes to the same branch queue but `main` and `dev` runs proceed in parallel without touching each other's state (different changelog files, different version commits). ### How a release runs 1. **Compute the next version** — [scripts/next-version.mjs](scripts/next-version.mjs) calls semantic-release in dry-run mode and prints exactly `NONE` or `1.10.0`-style on stdout. It routes semantic-release's own logs to stderr so the workflow can capture stdout cleanly. 2. **Skip if no release** — when version is `NONE`, every following step's `if:` short-circuits. -3. **Write `.env.production`** — webpack's [dotenv-webpack](webpack.config.js) is configured with `safe: true`, so all 8 keys from [.env.example](.env.example) must be present at build time. CI populates the file from 3 GitHub Actions secrets and 5 vars (see workflow `env:` block). +3. **Write `.env.production`** — webpack's [dotenv-webpack](webpack.config.js) is configured with `safe: true`, so all 8 keys from [.env.example](.env.example) must be present at build time. CI populates the file from 3 GitHub Actions secrets and 5 vars (see workflow `env:` block). The job's `environment:` line (resolved from the branch) routes `MIXPANEL_PROJECT_TOKEN`, `SUBTURTLE_API_URL`, and `SUBTURTLE_DASHBOARD_URL` to the matching `prod`/`dev` environment; the rest come from repo-level. See [§ Required GitHub Actions config](#required-github-actions-config). 4. **Bump versions for build** — `npm version --no-git-tag-version` writes `package.json`; [scripts/sync-manifest-version.mjs](scripts/sync-manifest-version.mjs) writes the same version to [static/manifest.json](static/manifest.json). 5. **Build & zip** — `yarn build && yarn zip` produces `subturtle.zip` with the new version baked in. 6. **Restore version files** — `git checkout -- package.json static/manifest.json` reverts the bump. This step exists deliberately: it lets `@semantic-release/git` see a real diff in step 7 and create the `chore(release): X.Y.Z [skip ci]` commit. Without restore, the diff is empty and no commit lands. -7. **Run `yarn release`** — `@semantic-release/npm` re-bumps `package.json`, [.releaserc.json](.releaserc.json) `prepareCmd` re-syncs `manifest.json`, `@semantic-release/git` commits both files and tags `vX.Y.Z`, `@semantic-release/github` creates the release with `subturtle.zip` attached. +7. **Run `yarn release`** — `@semantic-release/npm` re-bumps `package.json`, `@semantic-release/changelog` prepends release notes to the active changelog file (`CHANGELOG.md` on `main`, `CHANGELOG-DEV.md` on `dev`), [release.config.cjs](release.config.cjs) `prepareCmd` re-syncs `manifest.json`, `@semantic-release/git` commits all three files and tags `vX.Y.Z`, `@semantic-release/github` creates the release with `subturtle.zip` attached (auto-flagged as prerelease for dev runs). 8. **Upload zip artifact** — also published as a workflow artifact for offline access. ### Conventional Commits drive versioning @@ -152,25 +192,32 @@ If you squash-merge PRs, GitHub uses the **PR title** as the squash commit messa ### `prepareCmd` does not build -[.releaserc.json](.releaserc.json) `prepareCmd` only runs `scripts/sync-manifest-version.mjs`. The build/zip happen earlier as explicit workflow steps so they're visible in CI logs and have access to the env file. Don't move build/zip back into `prepareCmd`. +[release.config.cjs](release.config.cjs) `prepareCmd` only runs `scripts/sync-manifest-version.mjs`. The build/zip happen earlier as explicit workflow steps so they're visible in CI logs and have access to the env file. Don't move build/zip back into `prepareCmd`. + +### Dev channel (prereleases) + +Pushes to `dev` cut prereleases on the `dev` channel — versions look like `1.11.0-dev.1`, `1.11.0-dev.2`, etc. When `dev` lands on `main`, semantic-release promotes the next stable bump cleanly (e.g. `1.11.0-dev.3` → `1.11.0`). + +- **Two changelog files, never merged.** Stable runs prepend to [CHANGELOG.md](CHANGELOG.md); dev runs prepend to [CHANGELOG-DEV.md](CHANGELOG-DEV.md). The active file is selected at config-load time in [release.config.cjs](release.config.cjs) by reading `GITHUB_REF_NAME` (set by GitHub Actions) or, locally, `git rev-parse --abbrev-ref HEAD`. So `yarn release:dry` works correctly on a `dev` checkout without extra env. +- **Chrome-compatible `manifest.version`, plus `version_name` for prereleases.** Chrome MV3 only accepts 1–4 dot-separated integers in `manifest.version`, so [scripts/sync-manifest-version.mjs](scripts/sync-manifest-version.mjs) maps a prerelease `MAJOR.MINOR.PATCH-channel.N` to `MAJOR.MINOR.PATCH.N` for `version` and copies the full semver into `version_name` (which is what shows in `chrome://extensions`). Stable releases write only `version` and clear any stale `version_name`. +- **GitHub Release auto-flagging.** `@semantic-release/github` checks the branch's `prerelease` flag and marks the release accordingly — no extra config needed beyond the `branches` array in [release.config.cjs](release.config.cjs). +- **Comparison-ordering caveat.** A dev build (`1.11.0.5`) is a higher Chrome version than the stable `1.11.0`. If a tester installs a dev zip and later wants the stable zip of the same base version, Chrome will not auto-downgrade. Once `1.11.0` ships stable, the next dev push is `1.12.0-dev.1` → `1.12.0.1`, which is correctly higher. Acceptable for an internal channel; flag this if you ever wire dev builds to a Chrome Web Store listing. ### Required GitHub Actions config -When forking or moving the repo, recreate these on the new repo: +The release workflow targets one of two GitHub Environments per run, picked from the branch via `environment: ${{ github.ref_name == 'main' && 'prod' || 'dev' }}`. Push to `main` → `prod` environment; push to `dev` → `dev` environment. With the job bound to an environment, `${{ secrets.X }}` / `${{ vars.X }}` resolve environment-first then fall back to repo-level — so the `env:` block in the "Write .env.production" step is the same for both branches. + +**Per-environment** (`Settings → Environments → prod` / `dev`) — same keys in both, different values: +- Secret: `MIXPANEL_PROJECT_TOKEN` +- Variables: `SUBTURTLE_API_URL`, `SUBTURTLE_DASHBOARD_URL` -**Secrets** (`Settings → Secrets and variables → Actions → Secrets`): -- `MIXPANEL_PROJECT_TOKEN` -- `GOOGLE_OAUTH_CLIENT_ID` -- `GOOGLE_TRANSLATE_KEY` +**Repository-level** (`Settings → Secrets and variables → Actions`) — shared by both: +- Secrets: `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_TRANSLATE_KEY` +- Variables: `MIXPANEL_API_HOST`, `GOOGLE_TRANSLATE_PROXY_URL`, `UNINSTALL_FORM_URL` -**Variables** (`Settings → Secrets and variables → Actions → Variables`): -- `MIXPANEL_API_HOST` -- `GOOGLE_TRANSLATE_PROXY_URL` -- `UNINSTALL_FORM_URL` -- `SUBTURTLE_API_URL` -- `SUBTURTLE_DASHBOARD_URL` +When forking, recreate the two environments and the repo-level entries above. -The default `GITHUB_TOKEN` is enough for the bot to push the release commit and tag, as long as the `main` ruleset doesn't require PRs. Currently main only blocks force pushes and deletions; no PR rule. +The default `GITHUB_TOKEN` is enough for the bot to push the release commit and tag, as long as the `main` (or `dev`) ruleset doesn't require PRs. Currently main only blocks force pushes and deletions; no PR rule. ### Local rehearsal @@ -187,16 +234,119 @@ GITHUB_TOKEN=$(gh auth token) yarn release:dry This prints the version + notes that would be generated without writing anything or creating a release. +## Testing + +Three test layers, all wired into a single CI verify gate that blocks releases on a red. + +### Stack + +| Layer | Tool | Where | +| --- | --- | --- | +| Unit / component | Vitest + happy-dom + `@vue/test-utils` + `@pinia/testing` | [tests/](tests/) — `*.test.ts` | +| E2E (real Chromium with the unpacked extension loaded) | `@playwright/test` + `chromium.launchPersistentContext({ args: ['--load-extension=dist'] })` | [tests/e2e/](tests/e2e/) — `*.spec.ts` | +| Static type | `tsc --noEmit` via [scripts/typecheck.mjs](scripts/typecheck.mjs) | (whole repo) | + +The `.test.ts` / `.spec.ts` split keeps Vitest and Playwright from fighting over file ownership — Vitest's `exclude` config drops everything matching `**/*.spec.ts` and `tests/e2e/**`. + +### Vitest setup + +- [vitest.config.ts](vitest.config.ts) — happy-dom env. PostCSS is bypassed inline (the project's webpack-targeted [postcss.config.js](postcss.config.js) uses a custom `rem→px` plugin that Vite's loader rejects); unit tests don't import CSS so the bypass is invisible to component tests. +- [tests/setup.ts](tests/setup.ts) — hand-rolled in-memory `chrome.*` shim covering the surface the production code actually touches: `runtime.sendMessage` / `onMessage`, `storage.local` get/set, `storage.onChanged.addListener`, `tabs.query` / `sendMessage`, `i18n.getUILanguage`, `runtime.getURL`. Plus a module-level `vi.mock('mixpanel-browser', ...)` so analytics never fire, and a silenced `console.log`. Don't pull in `jest-chrome` / `sinon-chrome` — both are abandoned and over-engineered for this surface. +- Pinia stores get a fresh `createPinia()` per test in `beforeEach`. Cross-bundle bridge tests use real `window.dispatchEvent` (happy-dom provides a real DOM). + +### Playwright E2E setup + +- [playwright.config.ts](playwright.config.ts) — `webServer` auto-boots [tests/e2e/server.mjs](tests/e2e/server.mjs) (a ~30-line static-file server for fixture pages). Single worker (extensions don't parallelize cleanly under one persistent context). HTML report always emitted, uploaded as a CI artifact on every run. +- [tests/e2e/extension-fixture.ts](tests/e2e/extension-fixture.ts) — Playwright fixture that loads `dist/` as an unpacked extension and exposes `context`, `serviceWorker`, `extensionId`. Specs that need the extension import `{ test, expect }` from this file instead of `@playwright/test`. The `dist-artifacts.spec.ts` is fs-only and uses plain `@playwright/test`. +- Fixture pages live under [tests/e2e/fixtures/](tests/e2e/fixtures/): `index.html` (English, default 16px html font-size), `persian.html` (Persian RTL — regression target for the `btoa` / Latin1 bug class), `large-font.html` (24px html — regression target for the postcss `rem→px` rewrite). +- Don't run E2E against real `youtube.com` / `netflix.com` — flaky, slow, and breaks on selector changes outside our control. Nibble + ConsoleCrane both match `` in the manifest, so the local fixtures are enough for those flows. + +### Critical Chromium flags + +The fixture passes a specific args list to `launchPersistentContext`. **Changing them breaks CI silently.** + +- `--headless=new` — forces the *full* Chromium binary in new-headless mode. Without it, Playwright defers to `chrome-headless-shell` on Linux runners, which **does not load extensions**. Every `toBeAttached` for `#subturtle-{nibble,console-crane}-root` will time out at 10s. macOS happens to do the right thing without this flag, which makes it easy to drop accidentally. +- `--no-sandbox`, `--disable-setuid-sandbox`, `--disable-dev-shm-usage` — standard CI hygiene for Chromium under containerised runners. Harmless on macOS, sometimes required on Linux GitHub runners. +- `--disable-extensions-except=${dist}` + `--load-extension=${dist}` — load only our extension, nothing else. + +### CI verify gate + +[.github/workflows/release.yml](.github/workflows/release.yml) — a single workflow with two jobs. + +The new `verify` job runs on `push` AND `pull_request` to `main` / `dev`. Step order matters: + +1. Checkout + sibling `dashboard-app` clone (CI-only path; see Gotchas). +2. `yarn install --frozen-lockfile`. +3. Cache + install Playwright Chromium (`~/.cache/ms-playwright` keyed on `yarn.lock` hash). +4. **Type check** — `yarn typecheck`. +5. **Unit tests** — `yarn test`. +6. **Stub `.env.production`** — heredocs *non-empty* placeholder values for every key in `.env.example`. Do not regress this to `cp .env.example .env.production`: empty values cause `mixpanel.init("")` in [src/plugins/mixpanel.ts](src/plugins/mixpanel.ts) to throw synchronously during the content-script import chain, which silently halts every Vue mount before its top-level `log()` calls. Symptom: every browser-loading e2e test times out at `toBeAttached` for the content-script roots, with zero HTTP traffic in the trace after the page load. +7. **Build** — `yarn build`. +8. **E2E tests** — `yarn test:e2e`. +9. **Upload Playwright report** — runs on success or failure (gated by `hashFiles('playwright-report/**') != ''` so it skips silently when typecheck / unit tests fail before Playwright produces output). Pull with `gh run download -n playwright-report `. + +The existing `release` job is unchanged in body but now has `needs: verify` and `if: github.event_name == 'push'` so it skips on PRs and only fires after verify is green. + +### Typecheck wrapper ([scripts/typecheck.mjs](scripts/typecheck.mjs)) + +Wraps `tsc --noEmit` and suppresses two classes of upstream errors: + +- `node_modules/pilotui/*` — pilotui's `package.json` `exports.types` points at raw TS source, so tsc follows into `pilotui/src/vue.ts` which has a mismatched plugin signature against `vue3-perfect-scrollbar`. +- `../dashboard-app/*` — [src/stores/profile.ts](src/stores/profile.ts) walks the import chain into the sibling repo's frontend types, which re-export from server-side TS that depends on `mongoose` / `stripe` / `@modular-rest/server`. dashboard-app's own `node_modules` are usually present locally but are NOT installed in CI. + +Real errors in our own code still print full tsc output and fail. Clean runs print a single summary line so GitHub's log parser doesn't surface the suppressed errors as red `Error:` annotations in the UI. + +The Vue 3 SFC ambient declaration lives at [src/vue-shim.d.ts](src/vue-shim.d.ts); it must use `DefineComponent` (not Vue 2's default-export shape) or every `.vue` import in the routers gets typed as the bare `vue` module namespace. + +### Test file map + +``` +tests/ + setup.ts # chrome.* shim, mixpanel mock + route-params.test.ts # encode/decode Unicode round-trip + undefined edge case + console-crane-bridge.test.ts # window CustomEvent emit/listen contracts + console-crane-store.test.ts # toggleConsoleCrane / goBack / canGoBack / isOnMainPage + translate.service.test.ts # cache hit/miss + 24h TTL eviction (vi.useFakeTimers) + settings-host.test.ts # nibbleDisabledDomains normalize / toggle + language-detection.test.ts # RTL detection, title lookup, supported codes + selection-popup.test.ts # @mousedown.prevent.stop regression + nibble-surface.test.ts # bridge-driven hide/show + translate-card.test.ts # popup translate input flow + e2e/ + extension-fixture.ts # chromium.launchPersistentContext + extension load + server.mjs # static fixtures HTTP server + fixtures/ # index.html, persian.html, large-font.html + dist-artifacts.spec.ts # fs check of dist/ shape (no browser) + nibble-flow.spec.ts # content script mounting + Persian emitOpen + console-crane-lifecycle.spec.ts # modal stays open while Nibble toggles off + translate-flow.spec.ts # full Persian translate-and-save with page.route stubs + visual-scale.spec.ts # rem→px rewrite regression net +``` + +### Test totals + +79 unit / component tests across 9 files; 11 E2E specs across 5 files. Full suite runs in ~15s once Playwright's Chromium is warm. + ## Verification checklist -When changes touch the bundle layout, content scripts, or shared CSS: +Most of this is automated by `yarn test` + `yarn test:e2e` — the items below are what the test suite already pins, with cross-references to the spec files. Re-run them by hand only if you're touching code the suite can't reach (the YouTube / Netflix subtitle path) or if you want a manual sanity pass on a real site. + +Automated: + +- `dist/` shape — entry files present, no orphan numeric chunks, manifest declares all four content scripts. → [tests/e2e/dist-artifacts.spec.ts](tests/e2e/dist-artifacts.spec.ts). +- Both content scripts mount their roots on a generic page; exactly one `#subturtle-console-crane-root`. → [tests/e2e/nibble-flow.spec.ts](tests/e2e/nibble-flow.spec.ts). +- Selection → Subturtle icon → translated card → Save → ConsoleCrane opens with WordDetail rendering Persian content. → [tests/e2e/translate-flow.spec.ts](tests/e2e/translate-flow.spec.ts). +- Toggling Nibble OFF for a host **while ConsoleCrane is open** does NOT close the modal or release the body scroll lock. → [tests/e2e/console-crane-lifecycle.spec.ts](tests/e2e/console-crane-lifecycle.spec.ts). +- Popup translate input: auto-focus on open, spinner while pending, re-submit different word resets, no double-fetch on enter mash. → [tests/translate-card.test.ts](tests/translate-card.test.ts). +- Per-host Nibble toggle persists and normalizes (`www.` strip, case fold, dedup). → [tests/settings-host.test.ts](tests/settings-host.test.ts). +- ConsoleCrane on Persian / CJK / emoji inputs throws no `InvalidCharacterError` from `btoa` — covered at the encode level, the bridge level, and the full select-and-save flow. → [tests/route-params.test.ts](tests/route-params.test.ts), [tests/e2e/nibble-flow.spec.ts](tests/e2e/nibble-flow.spec.ts), [tests/e2e/translate-flow.spec.ts](tests/e2e/translate-flow.spec.ts). +- Visual scale is consistent on default-html-fontsize and 24px-html-fontsize hosts (postcss `rem→px` rewrite regression net). → [tests/e2e/visual-scale.spec.ts](tests/e2e/visual-scale.spec.ts). + +Still manual: -- `dist/` contains exactly: `background.js`, `main.js`, `nibble.js`, `popup.js`, `popup.html`, `manifest.json`, `assets/` (no orphan numeric chunks). -- On YouTube `/watch`: subtitle popup works; Nibble selection popup also works (both bundles run there since the manifest has overlapping matches). -- On Wikipedia: only `nibble.js` runs; selection → icon → translation card → save flow opens ConsoleCrane. -- In the popup: per-site toggle reads/writes `nibbleDisabledDomains` and survives a popup re-open. -- In ConsoleCrane on a non-Latin page (e.g. Persian / Chinese article): no `InvalidCharacterError` from `btoa`. -- Visual scale is consistent on a default-html-font-size site (YouTube) and a large-html-font-size site (typical WordPress blog). +- On YouTube `/watch`: subtitle popup wraps caption words, hover/anchor selection works, all three content scripts run side-by-side. The `main.js` URL match is locked to `youtube.com` and Netflix, so it can't be fixtured without a test-only manifest. +- On Netflix: same — subtitle wrapping behaviour on real Netflix. +- Popup full re-open lifecycle on the actual `chrome-extension:///popup.html` page (the unit suite covers individual components but not the popup-page mount + nav transitions). ## Useful pointers @@ -206,9 +356,21 @@ When changes touch the bundle layout, content scripts, or shared CSS: - Vue plugin setup: [src/plugins/install.ts](src/plugins/install.ts) - Background message types: [src/common/types/messaging.ts](src/common/types/messaging.ts) - ConsoleCrane store: [src/console-crane/stores/console-crane.ts](src/console-crane/stores/console-crane.ts) +- ConsoleCrane bridge: [src/common/services/console-crane-bridge.ts](src/common/services/console-crane-bridge.ts) +- Route-param helpers (Unicode-safe base64): [src/console-crane/route-params.ts](src/console-crane/route-params.ts) +- WordDetailModule (detailed result panel, prop- or route-driven): [src/console-crane/modules/word-detail/index.vue](src/console-crane/modules/word-detail/index.vue) +- Popup translate card: [src/popup/components/TranslateCard.vue](src/popup/components/TranslateCard.vue) - Settings store: [src/common/store/settings.ts](src/common/store/settings.ts) - Marker store: [src/stores/marker.ts](src/stores/marker.ts) - Translate service: [src/common/services/translate.service.ts](src/common/services/translate.service.ts) -- Release workflow: [.github/workflows/release.yml](.github/workflows/release.yml) -- semantic-release config: [.releaserc.json](.releaserc.json) +- Release + verify workflow: [.github/workflows/release.yml](.github/workflows/release.yml) +- semantic-release config: [release.config.cjs](release.config.cjs) +- Changelogs: [CHANGELOG.md](CHANGELOG.md) (stable), [CHANGELOG-DEV.md](CHANGELOG-DEV.md) (prerelease) - Version-bump helpers: [scripts/next-version.mjs](scripts/next-version.mjs), [scripts/sync-manifest-version.mjs](scripts/sync-manifest-version.mjs) +- Vitest config: [vitest.config.ts](vitest.config.ts) +- Vitest setup (chrome shim, mixpanel mock): [tests/setup.ts](tests/setup.ts) +- Playwright config: [playwright.config.ts](playwright.config.ts) +- Playwright extension fixture: [tests/e2e/extension-fixture.ts](tests/e2e/extension-fixture.ts) +- Playwright fixtures server: [tests/e2e/server.mjs](tests/e2e/server.mjs) +- Typecheck wrapper (with upstream-error filter): [scripts/typecheck.mjs](scripts/typecheck.mjs) +- Vue 3 SFC ambient declaration: [src/vue-shim.d.ts](src/vue-shim.d.ts) diff --git a/package.json b/package.json index 2a97089..675305f 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,34 @@ { "name": "subturtle-extension", - "version": "1.10.1", + "version": "1.11.0-dev.2", "private": true, "scripts": { "dev": "webpack --watch", "build": "NODE_ENV=production webpack --mode=production", "zip": "cd dist && zip -r subturtle.zip . && mv subturtle.zip ../subturtle.zip", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "typecheck": "node scripts/typecheck.mjs", "release": "semantic-release", "release:dry": "semantic-release --dry-run --no-ci" }, "devDependencies": { "@egoist/tailwindcss-icons": "1.7.1", "@iconify/json": "2.2.165", + "@pinia/testing": "^1", + "@playwright/test": "^1.49", + "@semantic-release/changelog": "^6.0.3", "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", "@types/chrome": "0.0.193", "@types/mixpanel-browser": "2.38.0", + "@vitejs/plugin-vue": "^5", + "@vue/test-utils": "^2", "copy-webpack-plugin": "11.0.0", "css-loader": "6.7.1", "dotenv-webpack": "8.0.1", + "happy-dom": "^15", "json-loader": "0.5.7", "postcss": "8.4.16", "postcss-loader": "7.0.1", @@ -31,6 +41,7 @@ "tailwindcss": "3", "ts-loader": "9.5.1", "typescript": "5.4.5", + "vitest": "^2", "vue-loader": "17.4.2", "vue-style-loader": "4.1.3", "vue-template-compiler": "2.7.16", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..6f7fdb8 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from "@playwright/test"; + +// E2E config — runs only against the Playwright specs in tests/e2e/. +// Vitest is configured to exclude this directory so the two suites don't +// fight over file ownership. +// +// `dist/` must be present before tests run; the e2e suite asserts artifacts +// and loads the extension into Chromium. CI runs `yarn build` before +// `yarn test:e2e` (see CLAUDE.md release pipeline notes). +export default defineConfig({ + testDir: "./tests/e2e", + testMatch: ["**/*.spec.ts"], + fullyParallel: false, + workers: 1, + // Always emit the HTML report so CI failures have an artifact we can + // upload and inspect (the verify workflow uploads playwright-report/ + // when a step fails). The list reporter stays so terminal output + // remains readable. + reporter: [["list"], ["html", { open: "never" }]], + + use: { + baseURL: "http://localhost:4173", + trace: "retain-on-failure", + }, + + webServer: { + command: "node tests/e2e/server.mjs", + url: "http://localhost:4173/", + reuseExistingServer: !process.env.CI, + stdout: "ignore", + stderr: "pipe", + timeout: 30_000, + }, +}); diff --git a/release.config.cjs b/release.config.cjs new file mode 100644 index 0000000..e8f8b23 --- /dev/null +++ b/release.config.cjs @@ -0,0 +1,54 @@ +const { execSync } = require("node:child_process"); + +function detectBranch() { + if (process.env.GITHUB_REF_NAME) return process.env.GITHUB_REF_NAME; + try { + return execSync("git rev-parse --abbrev-ref HEAD", { + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + } catch { + return "main"; + } +} + +const isDev = detectBranch() === "dev"; +const changelogFile = isDev ? "CHANGELOG-DEV.md" : "CHANGELOG.md"; + +module.exports = { + branches: ["main", { name: "dev", prerelease: true }], + plugins: [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + ["@semantic-release/changelog", { changelogFile }], + ["@semantic-release/npm", { npmPublish: false }], + [ + "@semantic-release/exec", + { + prepareCmd: + "node scripts/sync-manifest-version.mjs ${nextRelease.version}", + }, + ], + [ + "@semantic-release/git", + { + assets: ["package.json", "static/manifest.json", changelogFile], + message: + "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", + }, + ], + [ + "@semantic-release/github", + { + assets: [ + { + path: "subturtle.zip", + name: "subturtle-v${nextRelease.version}.zip", + label: "Subturtle Chrome extension (v${nextRelease.version})", + }, + ], + }, + ], + ], +}; diff --git a/scripts/sync-manifest-version.mjs b/scripts/sync-manifest-version.mjs index 75d03b5..fda9dbd 100644 --- a/scripts/sync-manifest-version.mjs +++ b/scripts/sync-manifest-version.mjs @@ -1,13 +1,28 @@ import { readFileSync, writeFileSync } from "node:fs"; -const version = process.argv[2]; -if (!version) { +const semver = process.argv[2]; +if (!semver) { console.error("Usage: node scripts/sync-manifest-version.mjs "); process.exit(1); } +const match = semver.match(/^(\d+\.\d+\.\d+)(?:-([a-z0-9]+)\.(\d+))?$/i); +if (!match) { + console.error(`Unrecognized version format: ${semver}`); + process.exit(1); +} +const [, base, channel, counter] = match; +const chromeVersion = channel ? `${base}.${counter}` : base; + const manifestPath = "static/manifest.json"; const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); -manifest.version = version; +manifest.version = chromeVersion; +if (channel) { + manifest.version_name = semver; +} else { + delete manifest.version_name; +} writeFileSync(manifestPath, JSON.stringify(manifest, null, "\t") + "\n"); -console.log(`Synced ${manifestPath} to ${version}`); +console.log( + `Synced ${manifestPath} → version=${chromeVersion}${channel ? `, version_name=${semver}` : ""}`, +); diff --git a/scripts/typecheck.mjs b/scripts/typecheck.mjs new file mode 100644 index 0000000..c1675e4 --- /dev/null +++ b/scripts/typecheck.mjs @@ -0,0 +1,60 @@ +// Wrapper around `tsc --noEmit` that filters out two classes of upstream +// errors we can't fix from this repo: +// +// 1. node_modules/pilotui/* — pilotui's package.json points `exports.types` +// at raw TS source, so tsc follows into pilotui/src/vue.ts which has a +// mismatched plugin signature against vue3-perfect-scrollbar. +// +// 2. ../dashboard-app/* — src/stores/profile.ts imports types from the +// sibling dashboard-app repo (see CLAUDE.md). dashboard-app's frontend +// types re-export from server-side TS that depends on mongoose / stripe +// / @modular-rest/server — installed in dashboard-app's own +// node_modules but not in ours. CI clones dashboard-app without +// installing its deps; locally maintainers usually have them. Either +// way, those errors aren't actionable from here. +// +// On clean runs we print only a short summary so GitHub's log parser +// doesn't surface the suppressed errors as red `Error:` annotations. +// Real errors in our own code still print the full tsc output and fail. +import { spawnSync } from "node:child_process"; + +const SUPPRESSED_PATH_FRAGMENTS = [ + "node_modules/pilotui/", + "dashboard-app/", +]; + +const FILE_AT_ERROR = /([^\s:]+\.(?:ts|tsx|d\.ts|vue))\(\d+,\d+\):\s*error\s+TS\d+/; + +function isSuppressed(line) { + const m = line.match(FILE_AT_ERROR); + if (!m) return false; + return SUPPRESSED_PATH_FRAGMENTS.some((frag) => m[1].includes(frag)); +} + +const r = spawnSync("npx", ["tsc", "--noEmit"], { encoding: "utf8" }); +const output = (r.stdout || "") + (r.stderr || ""); + +const allErrorLines = output.split("\n").filter((l) => /error TS\d+/.test(l)); +const realErrorLines = allErrorLines.filter((l) => !isSuppressed(l)); +const suppressedCount = allErrorLines.length - realErrorLines.length; + +if (realErrorLines.length > 0) { + // Print full tsc output so the user sees error context, then a summary. + console.error(output); + console.error( + `\n${realErrorLines.length} type error(s) in our code. Fix above.` + ); + if (suppressedCount > 0) { + console.error( + `(${suppressedCount} additional error(s) suppressed from pilotui / dashboard-app — see scripts/typecheck.mjs.)` + ); + } + process.exit(1); +} + +const suffix = + suppressedCount > 0 + ? ` (${suppressedCount} upstream error(s) from pilotui / dashboard-app suppressed — see scripts/typecheck.mjs)` + : ""; +console.log(`typecheck clean.${suffix}`); +process.exit(0); diff --git a/src/background.ts b/src/background.ts index 0914e13..a561a2b 100644 --- a/src/background.ts +++ b/src/background.ts @@ -42,12 +42,14 @@ function broadcastSettings(settings: SettingsObject) { // Tabs without our content script (chrome://, web store, freshly // installed pre-extension tabs, etc.) reject with "Receiving end does // not exist". That's expected for a fire-and-forget broadcast. - chrome.tabs - .sendMessage(tab.id, { + // Cast: @types/chrome@0.0.193 still types tabs.sendMessage as void; + // in MV3 the no-callback overload returns a Promise. + ( + chrome.tabs.sendMessage(tab.id, { type: MESSAGE_TYPE.SYNC_SETTINGS, settings, - }) - .catch(() => {}); + }) as unknown as Promise + ).catch(() => {}); } }); } diff --git a/src/common/services/console-crane-bridge.ts b/src/common/services/console-crane-bridge.ts new file mode 100644 index 0000000..cab6714 --- /dev/null +++ b/src/common/services/console-crane-bridge.ts @@ -0,0 +1,47 @@ +import type { ConsolePage } from "../../console-crane/types"; + +// In-page event bridge between feature bundles (subtitle, nibble) and the +// console-crane content script. All Subturtle content scripts share the same +// extension isolated world per tab, so window CustomEvents reach across them. +const OPEN_EVENT = "subturtle:console-crane:open"; +const STATE_EVENT = "subturtle:console-crane:state"; +const REQUEST_STATE_EVENT = "subturtle:console-crane:request-state"; + +export interface OpenPayload { + page: ConsolePage; + params?: Record; + active?: boolean; +} + +export interface StatePayload { + isActive: boolean; +} + +export function emitOpen(payload: OpenPayload): void { + window.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: payload })); +} + +export function onOpen(handler: (payload: OpenPayload) => void): () => void { + const listener = (e: Event) => handler((e as CustomEvent).detail); + window.addEventListener(OPEN_EVENT, listener); + return () => window.removeEventListener(OPEN_EVENT, listener); +} + +export function emitState(payload: StatePayload): void { + window.dispatchEvent(new CustomEvent(STATE_EVENT, { detail: payload })); +} + +export function onState(handler: (payload: StatePayload) => void): () => void { + const listener = (e: Event) => handler((e as CustomEvent).detail); + window.addEventListener(STATE_EVENT, listener); + return () => window.removeEventListener(STATE_EVENT, listener); +} + +export function requestState(): void { + window.dispatchEvent(new Event(REQUEST_STATE_EVENT)); +} + +export function onRequestState(handler: () => void): () => void { + window.addEventListener(REQUEST_STATE_EVENT, handler); + return () => window.removeEventListener(REQUEST_STATE_EVENT, handler); +} diff --git a/src/console-crane.ts b/src/console-crane.ts new file mode 100644 index 0000000..c9a3b4f --- /dev/null +++ b/src/console-crane.ts @@ -0,0 +1,51 @@ +import "./trusted-types-polyfill"; + +import "./animation.scss"; +import "./tailwind.css"; + +import { createApp, watch } from "vue"; + +import ConsoleCrane from "./console-crane/index.vue"; +import { initConsoleCraneApp } from "./console-crane/initializer"; +import { useConsoleCraneStore } from "./console-crane/stores/console-crane"; +import { addPlugins } from "./plugins/install"; +import { loginWithLastSession } from "./plugins/modular-rest"; +import { useSettingsStore } from "./common/store/settings"; +import { + emitState, + onOpen, + onRequestState, +} from "./common/services/console-crane-bridge"; +import { log } from "./common/helper/log"; +import { VERSION } from "./common/static/global"; + +log("ConsoleCrane using version", VERSION); + +(async () => { + const app = createApp(ConsoleCrane as any); + const vueApp = addPlugins(app); + + const settings = useSettingsStore(); + await settings.initialize(); + + loginWithLastSession(); + + await initConsoleCraneApp(vueApp); + + const store = useConsoleCraneStore(); + + onOpen(({ page, params, active }) => { + store.toggleConsoleCrane(page, params, active); + }); + + onRequestState(() => { + emitState({ isActive: store.isActive }); + }); + + watch( + () => store.isActive, + (isActive) => { + emitState({ isActive }); + } + ); +})(); diff --git a/src/console-crane/initializer.ts b/src/console-crane/initializer.ts new file mode 100644 index 0000000..324358e --- /dev/null +++ b/src/console-crane/initializer.ts @@ -0,0 +1,22 @@ +import { App } from "vue"; + +export const CONSOLE_CRANE_ROOT_ID = "subturtle-console-crane-root"; + +export async function initConsoleCraneApp(app: App): Promise { + let root = document.getElementById(CONSOLE_CRANE_ROOT_ID); + if (!root) { + root = document.createElement("div"); + root.id = CONSOLE_CRANE_ROOT_ID; + root.classList.add("subturtle-scope"); + root.style.position = "fixed"; + root.style.zIndex = "2147483647"; + root.style.top = "0"; + root.style.left = "0"; + root.style.width = "0"; + root.style.height = "0"; + document.body.appendChild(root); + } + + app.mount(root); + return app; +} diff --git a/src/console-crane/modules/word-detail/index.vue b/src/console-crane/modules/word-detail/index.vue index ac76933..818c97b 100644 --- a/src/console-crane/modules/word-detail/index.vue +++ b/src/console-crane/modules/word-detail/index.vue @@ -192,7 +192,16 @@ import { useRoute } from "vue-router"; import { sendMessage } from "../../../common/helper/massage"; import { OpenLoginWindowMessage } from "../../../common/types/messaging"; import { analytic } from "../../../plugins/mixpanel"; -import { decodeRouteParams } from "../../stores/console-crane"; +import { decodeRouteParams } from "../../route-params"; + +const props = defineProps<{ + word?: string; + context?: string; +}>(); + +const emit = defineEmits<{ + loading: [boolean]; +}>(); const route = useRoute(); @@ -201,10 +210,15 @@ onMounted(() => { }); /** - * Extracts word data from the route parameter - * The data is base64 encoded in the URL + * Resolve word + context inputs. Prefers explicit props (used by the popup + * bundle, which mounts this module without a route param). Falls back to + * the base64-encoded `:data` route param used by the console-crane router. */ function getProps() { + if (props.word) { + return { word: props.word, context: props.context ?? "" }; + } + const data = decodeRouteParams<{ word: string; context?: string }>( route.params.data as string ); @@ -222,6 +236,9 @@ const pending = ref(false); // Loading state const error = ref(null); // Translation error message, null when ok const key = ref(new Date().getTime()); // Key for forcing component refresh +// Mirror loading state to parent so popup callers can show a button spinner. +watch(pending, (val) => emit("loading", val)); + // Gets the title of the target language (e.g., "Spanish", "French") const targetLanguageTitle = computed( () => TranslateService.instance.languageTitle diff --git a/src/console-crane/route-params.ts b/src/console-crane/route-params.ts new file mode 100644 index 0000000..8986b24 --- /dev/null +++ b/src/console-crane/route-params.ts @@ -0,0 +1,28 @@ +// Unicode-safe base64 helpers for vue-router params. Lives in its own module +// (no router/store imports) so consumers like the popup's WordDetailModule +// can pull only what they need without dragging in the console-crane router +// — that import path causes a circular ESM init in non-console-crane bundles. +// +// `btoa` only accepts Latin1 — any non-Latin1 character (e.g. accented Latin, +// Persian, Chinese, emoji) throws InvalidCharacterError. We encode via +// TextEncoder so route params can carry any text. +// +// Undefined is a legitimate input — `toggleConsoleCrane(page)` calls this +// without explicit params for routes like "empty" / "settings". We round-trip +// it as an empty string so JSON.parse never sees an empty payload. +export function encodeRouteParams(params: any): string { + const json = JSON.stringify(params); + if (json === undefined) return ""; + const bytes = new TextEncoder().encode(json); + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); +} + +export function decodeRouteParams(data: string): T | undefined { + if (data === "") return undefined; + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return JSON.parse(new TextDecoder().decode(bytes)); +} diff --git a/src/console-crane/stores/console-crane.ts b/src/console-crane/stores/console-crane.ts index a5d6058..e0ae10b 100644 --- a/src/console-crane/stores/console-crane.ts +++ b/src/console-crane/stores/console-crane.ts @@ -3,29 +3,13 @@ import { ref, computed } from "vue"; import { ConsolePage } from "../types"; import { router } from "../router"; +import { encodeRouteParams } from "../route-params"; interface PageEntry { name: ConsolePage; params?: Record; } -// Unicode-safe base64. `btoa` only accepts Latin1 — any non-Latin1 character -// (e.g. accented Latin, Persian, Chinese, emoji) throws InvalidCharacterError. -// We encode via TextEncoder so route params can carry any text. -export function encodeRouteParams(params: any): string { - const bytes = new TextEncoder().encode(JSON.stringify(params)); - let binary = ""; - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); - return btoa(binary); -} - -export function decodeRouteParams(data: string): T { - const binary = atob(data); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return JSON.parse(new TextDecoder().decode(bytes)); -} - export const useConsoleCraneStore = defineStore("console-crane", () => { const isActive = ref(false); const history = ref([]); diff --git a/src/main.ts b/src/main.ts index 884ed61..65b8c2f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,6 @@ import "./tailwind.css"; import { App, createApp } from "vue"; import subtitleComponents from "./subtitle/components/components"; import generalComponents from "./common/components/components"; -import ConsoleCrane from "./console-crane/index.vue"; import { netflix } from "./subtitle/web_netflix/initializer"; import { youtube } from "./subtitle/web_youtube/initializer"; @@ -71,7 +70,6 @@ function start() { const components = { ...subtitleComponents, ...generalComponents, - ConsoleCrane, }; Object.keys(components).forEach((name) => { diff --git a/src/nibble.ts b/src/nibble.ts index 7228396..c52aee3 100644 --- a/src/nibble.ts +++ b/src/nibble.ts @@ -6,7 +6,6 @@ import "./tailwind.css"; import { createApp } from "vue"; import generalComponents from "./common/components/components"; -import ConsoleCrane from "./console-crane/index.vue"; import IndexVue from "./nibble/Index.vue"; import { initNibbleApp } from "./nibble/initializer"; @@ -34,7 +33,6 @@ log("Nibble using version", VERSION); const components = { ...generalComponents, - ConsoleCrane, }; Object.keys(components).forEach((name) => { diff --git a/src/nibble/components/NibbleSurface.vue b/src/nibble/components/NibbleSurface.vue index c087178..aee376a 100644 --- a/src/nibble/components/NibbleSurface.vue +++ b/src/nibble/components/NibbleSurface.vue @@ -1,20 +1,32 @@ diff --git a/src/nibble/components/SelectionPopup.vue b/src/nibble/components/SelectionPopup.vue index 5a504a2..75e2680 100644 --- a/src/nibble/components/SelectionPopup.vue +++ b/src/nibble/components/SelectionPopup.vue @@ -64,7 +64,7 @@ diff --git a/src/popup/components/PopupLoader.vue b/src/popup/components/PopupLoader.vue new file mode 100644 index 0000000..5811d5a --- /dev/null +++ b/src/popup/components/PopupLoader.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/src/popup/components/TranslateCard.vue b/src/popup/components/TranslateCard.vue new file mode 100644 index 0000000..f95611d --- /dev/null +++ b/src/popup/components/TranslateCard.vue @@ -0,0 +1,87 @@ + + + diff --git a/src/popup/router.ts b/src/popup/router.ts index 97910aa..e337717 100644 --- a/src/popup/router.ts +++ b/src/popup/router.ts @@ -37,22 +37,19 @@ export const router = createRouter({ routes: routes, }); -router.beforeEach(async (to, from) => { - // Allow access to intro and login pages regardless of login status +router.beforeEach(async (to) => { + // Intro and login pages bypass the silent re-auth attempt entirely. if (to.name === "intro" || to.name === "login") { return true; } - // Try to login with last session if not already logged in + // Best-effort silent re-auth so logged-in users hit a populated state on + // first paint. We deliberately do NOT redirect on failure — the home view + // (and the new translation card on it) is reachable for logged-out users; + // auth-gated UI inside HomeView is hidden via `v-if="isLogin"`. if (!isLogin.value) { await loginWithLastSession(); - - // After trying to login, check again if successful - if (!isLogin.value) { - return { name: "intro" }; - } } - // If we got here, user is logged in, allow navigation return true; }); diff --git a/src/popup/views/HelpView.vue b/src/popup/views/HelpView.vue index df1a13f..9e64035 100644 --- a/src/popup/views/HelpView.vue +++ b/src/popup/views/HelpView.vue @@ -35,280 +35,334 @@

- Follow these simple steps to enhance your learning experience + Three ways to capture words, one place to learn them.

-
-
- + +
+ class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-4 px-2" + > + Capture +
- -
-
-
-
- 1 -
-
-
+
+
-

- Open Video & Enable Subtitles +
+ + + +
+

+ Video subtitles

-

- Open a video on Netflix or YouTube, enable subtitles, and hover - over words to see instant translations. It's that simple to get - started! +

+ Open Netflix or YouTube, turn on subtitles, and hover any word + for an instant translation.

-
-
- - -
-
-
-
+ Click the + + - 2 -
+ anchors on either side of a word to extend the selection one + word at a time.
+ +
-

- Select Multiple Words -

-
-
+ - 1 - Hold - Ctrl - or - ⌘ Command -
-
- 2 - Drag to select multiple words for translation -
-
- 3 - Click to save selected words for later practice -
+ + +
+

+ Any webpage +

+

+ Highlight text on any site — Wikipedia, articles, blogs. A + floating Subturtle icon appears for an instant translation. +

+
+ Click the icon, then hit + Save & view + to open the full word details.
-
- -
-
-
-
+
+
+ - 3 -
+ + +
+

+ Quick translate +

+

+ Open this popup and type or paste any phrase. Get a translation + instantly, no page visit needed. +

+
+ Perfect for quick lookups while you're reading anywhere — even + a paper book.
+
+
+ + +
+
+ Learn +
+ +
+
-

- Save & Organize -

-
-
-
+ + +
+
+
+
- - - View detailed translations and examples + 1 +
-
- +
+

+ Save & Organize +

+
+
- - - Save important phrases to your collection -
-
- + + + View detailed translations and examples +
+
+ + + + Save important phrases to your collection +
+
- - - Create custom bundles for organized learning + + + + Group phrases into custom bundles +
-
- -
-
-
-
- 4 + +
+
+
+
+ 2 +
-
-
-

- Practice & Master -

-
-
-

- Practice Modes -

-
-
- +

+ Practice & Master +

+
+
+

+ Practice Modes +

+
+
- - - Interactive flashcards -
-
- + + + Interactive flashcards +
+
- - - AI-powered practice sessions + + + + AI-powered practice sessions +
-
-
-

- Track Progress -

-
-
- +

+ Track Progress +

+
+
- - - Organize word bundles -
-
- + + + Organize word bundles +
+
- - - Monitor learning progress + + + + Monitor learning progress +
@@ -316,6 +370,78 @@
+ + +
+

+ From this popup you can also… +

+
+
+ + + + Disable Subturtle on a specific website using the per-site + toggle. +
+
+ + + + Change the language you're learning from videos. +
+
+ + + + Open the dashboard to manage saved phrases and practice. +
+
+
diff --git a/src/popup/views/HomeView.vue b/src/popup/views/HomeView.vue index 9652e9b..c29cc66 100644 --- a/src/popup/views/HomeView.vue +++ b/src/popup/views/HomeView.vue @@ -3,35 +3,18 @@
- -
+ +
+ + + +
-
-
-
-

- Learn English by streaming your  - - favorite shows - -

-

- From subtitles to fluency. Learn from real-life, native content. -

-
-
- -
-
-
-
- -
+ + + Want to + +  Log in? + +
@@ -299,6 +295,7 @@ import { isLogin, logout } from "../../plugins/modular-rest"; import { useRouter } from "vue-router"; import { getSubturtleDashboardUrlWithToken } from "../../common/static/global"; import { useSettingsStore } from "../../common/store/settings"; +import TranslateCard from "../components/TranslateCard.vue"; const router = useRouter(); const isLoading = ref(false); diff --git a/src/subtitle/components/specific/TranslatedPhrase.vue b/src/subtitle/components/specific/TranslatedPhrase.vue index 043e398..d869167 100644 --- a/src/subtitle/components/specific/TranslatedPhrase.vue +++ b/src/subtitle/components/specific/TranslatedPhrase.vue @@ -17,12 +17,10 @@ import { cleanText } from "../../../common/helper/text"; import { computed, defineProps, onMounted, ref, watch } from "vue"; import { useMarkerStore } from "../../../stores/marker"; -import { useConsoleCraneStore } from "../../../console-crane/stores/console-crane"; import { IconButton } from "pilotui/elements"; const markerStore = useMarkerStore(); -const consoleCraneStore = useConsoleCraneStore(); const props = defineProps<{ textStyle: any; diff --git a/src/subtitle/components/specific/Word.vue b/src/subtitle/components/specific/Word.vue index 2174cf5..7d04fd9 100644 --- a/src/subtitle/components/specific/Word.vue +++ b/src/subtitle/components/specific/Word.vue @@ -13,12 +13,11 @@ diff --git a/tests/console-crane-bridge.test.ts b/tests/console-crane-bridge.test.ts new file mode 100644 index 0000000..24a5baf --- /dev/null +++ b/tests/console-crane-bridge.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + emitOpen, + onOpen, + emitState, + onState, + requestState, + onRequestState, + type OpenPayload, + type StatePayload, +} from "../src/common/services/console-crane-bridge"; + +// The bridge backs a cross-bundle contract (nibble / main feature scripts ←→ +// console-crane content script) over window CustomEvents. These tests pin the +// payload shape and subscribe/unsubscribe semantics so a refactor of the +// internals can't silently break the wire format. +describe("console-crane bridge", () => { + beforeEach(() => { + // happy-dom shares window across tests; nothing else holds listeners, + // but reset just in case test order shifts. + vi.restoreAllMocks(); + }); + + describe("emitOpen / onOpen", () => { + it("delivers payload intact to a registered listener", () => { + const handler = vi.fn(); + const off = onOpen(handler); + + const payload: OpenPayload = { + page: "word-detail", + params: { word: "hello", context: "ctx" }, + active: true, + }; + emitOpen(payload); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(payload); + off(); + }); + + it("delivers to multiple listeners and stops on unsubscribe", () => { + const a = vi.fn(); + const b = vi.fn(); + const offA = onOpen(a); + const offB = onOpen(b); + + emitOpen({ page: "settings" }); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + + offA(); + emitOpen({ page: "settings" }); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(2); + + offB(); + }); + }); + + describe("emitState / onState", () => { + it("delivers state payload intact", () => { + const handler = vi.fn(); + const off = onState(handler); + + const payload: StatePayload = { isActive: true }; + emitState(payload); + + expect(handler).toHaveBeenCalledWith(payload); + off(); + }); + }); + + describe("requestState / onRequestState", () => { + it("fires the request listener with no payload", () => { + const handler = vi.fn(); + const off = onRequestState(handler); + + requestState(); + expect(handler).toHaveBeenCalledTimes(1); + off(); + }); + + it("stops firing after unsubscribe", () => { + const handler = vi.fn(); + const off = onRequestState(handler); + + requestState(); + off(); + requestState(); + + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + it("open and state channels are independent", () => { + const onOpenHandler = vi.fn(); + const onStateHandler = vi.fn(); + const offOpen = onOpen(onOpenHandler); + const offState = onState(onStateHandler); + + emitOpen({ page: "empty" }); + expect(onOpenHandler).toHaveBeenCalledTimes(1); + expect(onStateHandler).not.toHaveBeenCalled(); + + emitState({ isActive: false }); + expect(onOpenHandler).toHaveBeenCalledTimes(1); + expect(onStateHandler).toHaveBeenCalledTimes(1); + + offOpen(); + offState(); + }); +}); diff --git a/tests/console-crane-store.test.ts b/tests/console-crane-store.test.ts new file mode 100644 index 0000000..d600f4c --- /dev/null +++ b/tests/console-crane-store.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { setActivePinia, createPinia } from "pinia"; + +// Mock the router so the test doesn't pull in the real page components and +// their service dependencies. We capture every router.push() call to assert +// the encoded params and route name. +const { mockPush } = vi.hoisted(() => ({ mockPush: vi.fn() })); +vi.mock("../src/console-crane/router", () => ({ + router: { push: mockPush }, +})); + +import { useConsoleCraneStore } from "../src/console-crane/stores/console-crane"; +import { decodeRouteParams } from "../src/console-crane/route-params"; + +describe("useConsoleCraneStore", () => { + beforeEach(() => { + setActivePinia(createPinia()); + mockPush.mockReset(); + }); + + describe("toggleConsoleCrane", () => { + it("activates the modal, pushes history, and routes with encoded params", () => { + const store = useConsoleCraneStore(); + const params = { word: "hello", context: "world" }; + + store.toggleConsoleCrane("word-detail", params); + + expect(store.isActive).toBe(true); + expect(store.history).toHaveLength(1); + expect(store.history[0]).toEqual({ name: "word-detail", params }); + + expect(mockPush).toHaveBeenCalledTimes(1); + const arg = mockPush.mock.calls[0][0]; + expect(arg.name).toBe("word-detail"); + expect(decodeRouteParams(arg.params.data)).toEqual(params); + }); + + it("flips isActive when called twice without explicit active flag", () => { + const store = useConsoleCraneStore(); + + store.toggleConsoleCrane("empty"); + expect(store.isActive).toBe(true); + + store.toggleConsoleCrane("empty"); + expect(store.isActive).toBe(false); + }); + + it("force-activates when called with active:true even if already active", () => { + const store = useConsoleCraneStore(); + store.toggleConsoleCrane("word-detail", { word: "a" }, true); + expect(store.isActive).toBe(true); + + store.toggleConsoleCrane("word-detail", { word: "b" }, true); + expect(store.isActive).toBe(true); + expect(store.history).toHaveLength(2); + }); + + it("does not push to history when the page+params are identical", () => { + const store = useConsoleCraneStore(); + store.toggleConsoleCrane("word-detail", { word: "x" }, true); + store.toggleConsoleCrane("word-detail", { word: "x" }, true); + expect(store.history).toHaveLength(1); + }); + + it("survives non-Latin1 params (Unicode-safe encoding)", () => { + const store = useConsoleCraneStore(); + const params = { word: "سلام", context: "你好 🐢" }; + + expect(() => + store.toggleConsoleCrane("word-detail", params, true) + ).not.toThrow(); + + const arg = mockPush.mock.calls[0][0]; + expect(decodeRouteParams(arg.params.data)).toEqual(params); + }); + }); + + describe("goBack", () => { + it("pops history and routes to the previous entry", () => { + const store = useConsoleCraneStore(); + store.toggleConsoleCrane("word-detail", { word: "first" }, true); + store.toggleConsoleCrane("word-detail", { word: "second" }, true); + expect(store.history).toHaveLength(2); + + mockPush.mockClear(); + store.goBack(); + + expect(store.history).toHaveLength(1); + expect(mockPush).toHaveBeenCalledTimes(1); + const arg = mockPush.mock.calls[0][0]; + expect(decodeRouteParams(arg.params.data)).toEqual({ word: "first" }); + }); + + it("is a no-op when history has one entry or fewer", () => { + const store = useConsoleCraneStore(); + store.goBack(); + expect(mockPush).not.toHaveBeenCalled(); + + store.toggleConsoleCrane("empty", undefined, true); + mockPush.mockClear(); + + store.goBack(); + expect(mockPush).not.toHaveBeenCalled(); + expect(store.history).toHaveLength(1); + }); + }); + + describe("derived state", () => { + it("canGoBack reflects history depth", () => { + const store = useConsoleCraneStore(); + expect(store.canGoBack).toBe(false); + + store.toggleConsoleCrane("word-detail", { a: 1 }, true); + expect(store.canGoBack).toBe(false); + + store.toggleConsoleCrane("word-detail", { a: 2 }, true); + expect(store.canGoBack).toBe(true); + }); + + it("isOnMainPage is true for empty/word-detail and false for settings", () => { + const store = useConsoleCraneStore(); + expect(store.isOnMainPage).toBe(true); // empty history defaults true + + store.toggleConsoleCrane("word-detail", { a: 1 }, true); + expect(store.isOnMainPage).toBe(true); + + store.toggleConsoleCrane("settings", undefined, true); + expect(store.isOnMainPage).toBe(false); + }); + }); + + it("resetHistory clears the stack", () => { + const store = useConsoleCraneStore(); + store.toggleConsoleCrane("word-detail", { a: 1 }, true); + store.toggleConsoleCrane("word-detail", { a: 2 }, true); + store.resetHistory(); + expect(store.history).toEqual([]); + expect(store.canGoBack).toBe(false); + }); +}); diff --git a/tests/e2e/console-crane-lifecycle.spec.ts b/tests/e2e/console-crane-lifecycle.spec.ts new file mode 100644 index 0000000..98f3587 --- /dev/null +++ b/tests/e2e/console-crane-lifecycle.spec.ts @@ -0,0 +1,149 @@ +import { test, expect } from "./extension-fixture"; + +// Regression net for the documented "modal lifecycle is decoupled from the +// Nibble per-host gate" contract (see CLAUDE.md verification checklist). +// Toggling Nibble OFF for the host while ConsoleCrane is open must not close +// the modal or release the body scroll lock. + +test.describe("ConsoleCrane modal lifecycle vs Nibble per-host toggle", () => { + test("modal stays open and body scroll remains locked when Nibble is disabled mid-session", async ({ + context, + serviceWorker, + }) => { + const page = await context.newPage(); + await page.goto("/index.html"); + + // Wait for both content scripts to mount and the settings store to do + // its initial fetch from background. + await expect(page.locator("#subturtle-console-crane-root")).toBeAttached({ + timeout: 10_000, + }); + await expect(page.locator("#subturtle-nibble-root")).toBeAttached({ + timeout: 10_000, + }); + + // Open the modal via the cross-bundle bridge (same path the Nibble icon + // uses in production). + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent("subturtle:console-crane:open", { + detail: { + page: "word-detail", + params: { word: "hi" }, + active: true, + }, + }) + ); + }); + + const modalSection = page.locator( + "#subturtle-console-crane section.absolute.rounded-xl" + ); + await expect(modalSection).toBeVisible({ timeout: 5_000 }); + + // The modal sets `document.body.style.overflowY = "hidden"` while open + // (see src/console-crane/components/Modal.vue). + await expect + .poll(async () => + page.evaluate(() => document.body.style.overflowY) + ) + .toBe("hidden"); + + // Toggle Nibble OFF for localhost by writing to chrome.storage from the + // service worker. Both content scripts listen on + // `chrome.storage.onChanged` and update their settings stores reactively + // — exactly the path the popup uses when the user flips the per-host + // switch. + await serviceWorker.evaluate(async () => { + const existing = await chrome.storage.local.get("settings"); + const settings = (existing.settings as any) || {}; + await chrome.storage.local.set({ + settings: { + theme: settings.theme ?? "dark", + language: settings.language ?? "en", + nibbleDisabledDomains: ["localhost"], + }, + }); + }); + + // Give the storage event a moment to propagate to the page's content + // scripts and Vue to react. + await page.waitForTimeout(300); + + // Modal must still be open. + await expect(modalSection).toBeVisible(); + + // Body scroll lock must still be held. + expect( + await page.evaluate(() => document.body.style.overflowY) + ).toBe("hidden"); + }); + + test("closing the modal afterwards still restores body scroll", async ({ + context, + serviceWorker, + }) => { + const page = await context.newPage(); + await page.goto("/index.html"); + + await expect(page.locator("#subturtle-console-crane-root")).toBeAttached({ + timeout: 10_000, + }); + + // Capture the original overflow-y so we know what "restored" means. + const originalOverflow = await page.evaluate( + () => document.body.style.overflowY + ); + + // Open the modal. + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent("subturtle:console-crane:open", { + detail: { + page: "word-detail", + params: { word: "hi" }, + active: true, + }, + }) + ); + }); + + const modalSection = page.locator( + "#subturtle-console-crane section.absolute.rounded-xl" + ); + await expect(modalSection).toBeVisible({ timeout: 5_000 }); + + // Toggle Nibble off mid-session. + await serviceWorker.evaluate(async () => { + await chrome.storage.local.set({ + settings: { + theme: "dark", + language: "en", + nibbleDisabledDomains: ["localhost"], + }, + }); + }); + await page.waitForTimeout(150); + + // Now close the modal. + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent("subturtle:console-crane:open", { + detail: { + page: "word-detail", + params: { word: "hi" }, + active: false, + }, + }) + ); + }); + + // Modal hides + body scroll restored (Modal.vue restores after a 100ms wait). + await expect(modalSection).toBeHidden({ timeout: 5_000 }); + await expect + .poll(async () => + page.evaluate(() => document.body.style.overflowY) + ) + .toBe(originalOverflow); + }); +}); diff --git a/tests/e2e/dist-artifacts.spec.ts b/tests/e2e/dist-artifacts.spec.ts new file mode 100644 index 0000000..ebf96ba --- /dev/null +++ b/tests/e2e/dist-artifacts.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +// Pure node test — no browser. Pins the build output shape so a +// stray webpack code-splitting flag (numbered chunks) or a missing +// entry can't slip past CI. +test.describe("dist build artifacts", () => { + const dist = path.resolve(process.cwd(), "dist"); + + test("contains every required entry file", () => { + const required = [ + "background.js", + "main.js", + "nibble.js", + "console-crane.js", + "popup.js", + "popup.html", + "manifest.json", + "assets", + ]; + for (const f of required) { + expect(fs.existsSync(path.join(dist, f)), `dist/${f} missing`).toBe(true); + } + }); + + test("does not produce orphan numeric chunks", () => { + const files = fs.readdirSync(dist); + const orphans = files.filter((f) => /^\d+\.js$/.test(f)); + expect(orphans).toEqual([]); + }); + + test("manifest declares the four expected content_scripts", () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(dist, "manifest.json"), "utf8") + ); + expect(manifest.manifest_version).toBe(3); + + const scripts = (manifest.content_scripts ?? []).flatMap( + (b: any) => b.js ?? [] + ); + expect(scripts).toContain("main.js"); + expect(scripts).toContain("nibble.js"); + expect(scripts).toContain("console-crane.js"); + }); +}); diff --git a/tests/e2e/extension-fixture.ts b/tests/e2e/extension-fixture.ts new file mode 100644 index 0000000..77f3bd7 --- /dev/null +++ b/tests/e2e/extension-fixture.ts @@ -0,0 +1,61 @@ +import { + test as base, + chromium, + type BrowserContext, + type Worker, +} from "@playwright/test"; +import path from "node:path"; + +// Loads the unpacked extension from `dist/` into a persistent context. +// Each test gets its own user-data-dir (empty string = ephemeral) so +// extension state doesn't leak between tests. The serviceWorker fixture +// resolves the MV3 background worker once it registers. +type ExtensionFixtures = { + context: BrowserContext; + serviceWorker: Worker; + extensionId: string; +}; + +export const test = base.extend({ + context: async ({}, use) => { + const pathToExtension = path.resolve(process.cwd(), "dist"); + + // Why these flags: + // - `--headless=new` forces the *full* Chromium binary in new-headless + // mode. Without it, Playwright defers to `chrome-headless-shell`, + // which is smaller and faster but does NOT load extensions — every + // content-script root assertion times out on Linux CI as a result. + // - `--no-sandbox` + `--disable-setuid-sandbox` + `--disable-dev-shm-usage` + // are standard CI hygiene for Chromium under containerised runners. + // Harmless on macOS, required on some Linux setups. + const context = await chromium.launchPersistentContext("", { + channel: "chromium", + args: [ + "--headless=new", + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + ], + }); + await use(context); + await context.close(); + }, + + serviceWorker: async ({ context }, use) => { + let [worker] = context.serviceWorkers(); + if (!worker) { + worker = await context.waitForEvent("serviceworker"); + } + await use(worker); + }, + + extensionId: async ({ serviceWorker }, use) => { + // SW URL: chrome-extension:///background.js + const id = new URL(serviceWorker.url()).host; + await use(id); + }, +}); + +export const expect = test.expect; diff --git a/tests/e2e/fixtures/index.html b/tests/e2e/fixtures/index.html new file mode 100644 index 0000000..dd88e59 --- /dev/null +++ b/tests/e2e/fixtures/index.html @@ -0,0 +1,24 @@ + + + + + + Subturtle E2E fixture — English + + + +

English text fixture

+

+ The quick brown amphibious turtle jumps over + the lazy fox while the early bird catches the proverbial worm. +

+

+ A second paragraph exists so that selection rectangles have room to land + somewhere visible. +

+ + diff --git a/tests/e2e/fixtures/large-font.html b/tests/e2e/fixtures/large-font.html new file mode 100644 index 0000000..8134817 --- /dev/null +++ b/tests/e2e/fixtures/large-font.html @@ -0,0 +1,24 @@ + + + + + + Subturtle E2E fixture — large root font-size + + + +

Large root font-size fixture

+

+ Selecting a vocabulary word here should give + the same Subturtle UI scale as on the default-font-size fixture. +

+ + diff --git a/tests/e2e/fixtures/persian.html b/tests/e2e/fixtures/persian.html new file mode 100644 index 0000000..9552e53 --- /dev/null +++ b/tests/e2e/fixtures/persian.html @@ -0,0 +1,23 @@ + + + + + + Subturtle E2E fixture — Persian + + + +

متن آزمایشی

+

+ سلام دنیا — این یک پاراگراف برای تست انتخاب کلمه + با حروف غیر لاتین است. 🐢 emoji and accented Latin café also live here. +

+

+ پاراگراف دوم برای دادن فضا به مستطیل انتخاب. +

+ + diff --git a/tests/e2e/nibble-flow.spec.ts b/tests/e2e/nibble-flow.spec.ts new file mode 100644 index 0000000..2d331d8 --- /dev/null +++ b/tests/e2e/nibble-flow.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from "./extension-fixture"; + +// Tests below load fixture pages served by the local static server in +// playwright.config.ts. The extension's nibble + console-crane content +// scripts both match `` so they run on these URLs. + +async function gotoAndWait( + page: Awaited> extends never ? never : any, + url: string +) { + await page.goto(url); + // Both content scripts mount their root after Pinia init + a settings + // round-trip to background — give them a moment. + await expect(page.locator("#subturtle-nibble-root")).toBeAttached({ + timeout: 10_000, + }); + await expect(page.locator("#subturtle-console-crane-root")).toBeAttached({ + timeout: 10_000, + }); +} + +test.describe("content script mounting", () => { + test("nibble + console-crane roots mount on a generic page", async ({ + context, + }) => { + const page = await context.newPage(); + await gotoAndWait(page, "/index.html"); + + // The verification checklist requires exactly one ConsoleCrane root. + expect( + await page.locator("#subturtle-console-crane-root").count() + ).toBe(1); + expect(await page.locator("#subturtle-nibble-root").count()).toBe(1); + }); +}); + +test.describe("ConsoleCrane bridge — Unicode params", () => { + test("emitOpen with Persian + emoji params does not throw InvalidCharacterError", async ({ + context, + }) => { + const page = await context.newPage(); + + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + await gotoAndWait(page, "/persian.html"); + + // Drive the bridge directly with Persian + emoji so we test the + // encodeRouteParams path that previously crashed under btoa. + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent("subturtle:console-crane:open", { + detail: { + page: "word-detail", + params: { word: "سلام", context: "این یک متن است 🐢 café 你好" }, + active: true, + }, + }) + ); + }); + + // Give Vue + the router a tick to react. + await page.waitForTimeout(250); + + const unicodeErrors = errors.filter( + (e) => /InvalidCharacterError|Latin1/i.test(e) + ); + expect(unicodeErrors, errors.join("\n")).toEqual([]); + }); +}); + +test.describe("Nibble selection popup", () => { + test("double-clicking a word shows the Subturtle icon", async ({ + context, + }) => { + const page = await context.newPage(); + await gotoAndWait(page, "/index.html"); + + await page.locator("#test-word").click({ clickCount: 2 }); + + await expect(page.locator(".nibble-icon-btn")).toBeVisible({ + timeout: 5_000, + }); + }); +}); diff --git a/tests/e2e/server.mjs b/tests/e2e/server.mjs new file mode 100644 index 0000000..b1cec92 --- /dev/null +++ b/tests/e2e/server.mjs @@ -0,0 +1,37 @@ +// Tiny static-file server for E2E fixtures. Avoids pulling in `serve` / +// `http-server` as a devDep just to back the Playwright tests. +import http from "node:http"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(here, "fixtures"); +const PORT = Number(process.env.PORT || 4173); + +const types = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript", + ".json": "application/json; charset=utf-8", +}; + +const server = http.createServer(async (req, res) => { + try { + const u = new URL(req.url ?? "/", `http://localhost:${PORT}`); + let file = path.join(ROOT, decodeURIComponent(u.pathname)); + const stat = await fs.stat(file).catch(() => null); + if (stat?.isDirectory()) file = path.join(file, "index.html"); + const data = await fs.readFile(file); + res.writeHead(200, { + "content-type": types[path.extname(file)] || "text/plain", + }); + res.end(data); + } catch { + res.writeHead(404).end("not found"); + } +}); + +server.listen(PORT, () => { + console.log(`fixtures at http://localhost:${PORT}`); +}); diff --git a/tests/e2e/translate-flow.spec.ts b/tests/e2e/translate-flow.spec.ts new file mode 100644 index 0000000..2843608 --- /dev/null +++ b/tests/e2e/translate-flow.spec.ts @@ -0,0 +1,155 @@ +import { test, expect } from "./extension-fixture"; +import type { Route, Page } from "@playwright/test"; + +// End-to-end translate-and-save regression test for Persian / non-Latin1 +// content. The translation backend (`POST /function/run` via modular-rest) +// is stubbed at the network layer so the test runs offline and pins the +// Persian payload through the entire pipeline: +// +// selection → Subturtle icon → simple translate stub → translated card → +// Save → ConsoleCrane opens (encodeRouteParams round-trips Persian) → +// detailed translate stub → WordDetail renders Persian content. +// +// The encode/decode + bridge integration tests cover the same regression +// in isolation; this one cements the contract end-to-end. + +async function stubTranslate(page: Page) { + await page.route("**/function/run", async (route: Route) => { + const body = route.request().postDataJSON(); + if (body?.name === "translateWithContext") { + const phrase: string = body.args?.phrase ?? ""; + const context: string = body.args?.context ?? ""; + + if (body.args?.translationType === "simple") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: `[stub] ${phrase}` }), + }); + } + + // detailed + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + phrase, + context, + phonetic: "/səˈlɑːm/", + definition: "hello / peace", + translation: "[detailed] " + phrase, + examples: [], + related: [], + }, + }), + }); + } + return route.fallback(); + }); + + // Anonymous login + bootstrap calls aren't relevant to this flow but their + // failures pollute console.error noise that the test asserts against. + await page.route("**/user/**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: {} }), + }) + ); +} + +test.describe("Persian translate-and-save flow", () => { + test("simple translate renders the stubbed result without InvalidCharacterError", async ({ + context, + }) => { + const page = await context.newPage(); + + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + await stubTranslate(page); + + await page.goto("/persian.html"); + await expect(page.locator("#subturtle-nibble-root")).toBeAttached({ + timeout: 10_000, + }); + + // Double-click the Persian word to select it; the Subturtle icon + // appears next to the selection rect. + await page.locator("#salam").click({ clickCount: 2 }); + await expect(page.locator(".nibble-icon-btn")).toBeVisible({ + timeout: 5_000, + }); + + // Click the icon → loading → translated card. + await page.locator(".nibble-icon-btn").click(); + + const translation = page.locator(".nibble-translation"); + await expect(translation).toBeVisible({ timeout: 5_000 }); + await expect(translation).toContainText("سلام"); + + expect( + errors.filter((e) => /InvalidCharacterError|Latin1/i.test(e)), + errors.join("\n") + ).toEqual([]); + + await page.close(); + }); + + test("Save click opens ConsoleCrane with Persian params surviving encodeRouteParams", async ({ + context, + }) => { + const page = await context.newPage(); + + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + await stubTranslate(page); + + await page.goto("/persian.html"); + await expect(page.locator("#subturtle-console-crane-root")).toBeAttached({ + timeout: 10_000, + }); + + // Drive selection → icon → translated card. + await page.locator("#salam").click({ clickCount: 2 }); + await expect(page.locator(".nibble-icon-btn")).toBeVisible({ + timeout: 5_000, + }); + await page.locator(".nibble-icon-btn").click(); + await expect(page.locator(".nibble-translation")).toBeVisible({ + timeout: 5_000, + }); + + // Click Save & view → emitOpen({ word: 'سلام', context: '...' }) → + // ConsoleCrane bridge → toggleConsoleCrane → encodeRouteParams → + // router.push. This is the path the original btoa Unicode bug crashed. + await page.locator(".nibble-save-btn").click(); + + const modalSection = page.locator( + "#subturtle-console-crane section.absolute.rounded-xl" + ); + await expect(modalSection).toBeVisible({ timeout: 5_000 }); + + // Wait for the detailed translate stub to resolve and WordDetail to + // render the Persian content end-to-end. + await expect(page.locator("#subturtle-console-crane")).toContainText( + "سلام", + { timeout: 5_000 } + ); + + expect( + errors.filter((e) => /InvalidCharacterError|Latin1/i.test(e)), + errors.join("\n") + ).toEqual([]); + + await page.close(); + }); +}); diff --git a/tests/e2e/visual-scale.spec.ts b/tests/e2e/visual-scale.spec.ts new file mode 100644 index 0000000..8178e57 --- /dev/null +++ b/tests/e2e/visual-scale.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "./extension-fixture"; +import type { Page, BrowserContext } from "@playwright/test"; + +// Regression net for the rem→px rewrite in postcss.config.js. Tailwind +// utilities ship as `rem`-based values; on a host with html { font-size: 24px } +// (common on news / WordPress templates), unrewritten rem would scale every +// Subturtle utility 1.5×, blowing out the modal. The rewrite pins rem to a +// fixed 14px base at build time so the rendered px is independent of host. +// +// We test the most direct vector: read the computed font-size of an element +// inside the rendered ConsoleCrane modal and assert it's identical on a +// 16px-html page vs a 24px-html page. +async function gotoConsoleCrane(context: BrowserContext, fixture: string) { + const page = await context.newPage(); + await page.goto(fixture); + + await expect(page.locator("#subturtle-console-crane-root")).toBeAttached({ + timeout: 10_000, + }); + + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent("subturtle:console-crane:open", { + detail: { + page: "word-detail", + params: { word: "hi" }, + active: true, + }, + }) + ); + }); + + return page; +} + +async function readModalSampleFontSize(page: Page) { + // The Dashboard button in the modal header carries `text-sm` plus + // `px-2 py-1`, all of which were rem-based pre-rewrite. Its computed + // font-size pins 0.875rem to 12.25px after the 14px-base rewrite. + const sample = page + .locator("#subturtle-console-crane button") + .filter({ hasText: "Dashboard" }); + await expect(sample).toBeVisible({ timeout: 10_000 }); + + return sample.evaluate( + (el) => parseFloat(getComputedStyle(el as HTMLElement).fontSize) + ); +} + +test.describe("postcss rem→px rewrite", () => { + test("text-sm renders at the same px size on 16px-html and 24px-html hosts", async ({ + context, + }) => { + const tinyPage = await gotoConsoleCrane(context, "/index.html"); + const tinyPx = await readModalSampleFontSize(tinyPage); + await tinyPage.close(); + + const largePage = await gotoConsoleCrane(context, "/large-font.html"); + const largePx = await readModalSampleFontSize(largePage); + await largePage.close(); + + // Same rendered size, ±0.5px for sub-pixel rounding. If the rewrite ever + // breaks, the 24px-html host renders text-sm at ~21px — way outside this. + expect(Math.abs(tinyPx - largePx)).toBeLessThanOrEqual(0.5); + + // Sanity floor: a broken rewrite typically inflates px above 18. + expect(tinyPx).toBeLessThan(18); + expect(largePx).toBeLessThan(18); + }); +}); diff --git a/tests/language-detection.test.ts b/tests/language-detection.test.ts new file mode 100644 index 0000000..24f00db --- /dev/null +++ b/tests/language-detection.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { LanguageDetector } from "../src/common/helper/language-detection"; + +describe("LanguageDetector pure helpers", () => { + describe("isRTLLanguage", () => { + it.each(["ar", "he", "fa", "ur", "ps", "sd", "yi", "dv"])( + "returns true for RTL language %s", + (code) => { + expect(LanguageDetector.isRTLLanguage(code)).toBe(true); + } + ); + + it.each(["en", "es", "fr", "zh-CN", "ja"])( + "returns false for LTR language %s", + (code) => { + expect(LanguageDetector.isRTLLanguage(code)).toBe(false); + } + ); + }); + + describe("getTextDirection", () => { + it("returns rtl for Persian", () => { + expect(LanguageDetector.getTextDirection("fa")).toBe("rtl"); + }); + + it("returns ltr for English", () => { + expect(LanguageDetector.getTextDirection("en")).toBe("ltr"); + }); + }); + + describe("getLanguageTitle", () => { + it("looks up known codes", () => { + expect(LanguageDetector.getLanguageTitle("en")).toBe("English"); + expect(LanguageDetector.getLanguageTitle("fa")).toBe("Persian"); + expect(LanguageDetector.getLanguageTitle("zh-CN")).toBe( + "Chinese (Simplified)" + ); + }); + + it("returns null for unknown codes", () => { + expect(LanguageDetector.getLanguageTitle("xx")).toBeNull(); + expect(LanguageDetector.getLanguageTitle("")).toBeNull(); + }); + }); + + describe("isLanguageSupported", () => { + it("returns true for codes present in the registry", () => { + expect(LanguageDetector.isLanguageSupported("en")).toBe(true); + expect(LanguageDetector.isLanguageSupported("fa")).toBe(true); + }); + + it("returns false for unknown codes", () => { + expect(LanguageDetector.isLanguageSupported("xx")).toBe(false); + }); + }); + + describe("getSupportedLanguageCodes", () => { + it("includes the common codes", () => { + const codes = LanguageDetector.getSupportedLanguageCodes(); + expect(codes).toContain("en"); + expect(codes).toContain("fa"); + expect(codes).toContain("zh-CN"); + expect(codes).toContain("zh-TW"); + }); + }); +}); + +describe("LanguageDetector chrome.* integration", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("setLanguage writes the code to chrome.storage.local", () => { + const set = (globalThis as any).chrome.storage.local.set as ReturnType< + typeof vi.fn + >; + set.mockClear(); + + LanguageDetector.setLanguage("fa"); + + expect(set).toHaveBeenCalledWith({ target: "fa" }); + }); +}); diff --git a/tests/nibble-surface.test.ts b/tests/nibble-surface.test.ts new file mode 100644 index 0000000..02e8fa8 --- /dev/null +++ b/tests/nibble-surface.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; +import { nextTick } from "vue"; + +// Mock the text-selection composable so we can drive isVisible directly. +// vi.hoisted lets the mock factory share state with the test body — the +// factory runs before the importer, but we expose the refs through the +// hoisted singleton so tests can read/write them. +const { selection } = vi.hoisted(() => ({ + selection: { + isVisible: undefined as any, + text: undefined as any, + rect: undefined as any, + contextText: undefined as any, + clear: vi.fn(), + }, +})); + +vi.mock("../src/nibble/composables/useTextSelection", async () => { + const { ref } = await import("vue"); + selection.isVisible = ref(false); + selection.text = ref(""); + selection.rect = ref(null); + selection.contextText = ref(""); + return { useTextSelection: () => selection }; +}); + +// SelectionPopup pulls in TranslateService → @modular-rest/client. Mock so +// the child stub doesn't trigger a real fetch chain just by importing. +vi.mock("@modular-rest/client", () => ({ + functionProvider: { run: vi.fn() }, +})); + +import NibbleSurface from "../src/nibble/components/NibbleSurface.vue"; +import { emitState } from "../src/common/services/console-crane-bridge"; + +// Regression test for the "modal closes when Nibble toggled off" / +// "selection popup leaks while modal is open" bug class. NibbleSurface owns +// a `v-if="selection.isVisible && !isModalActive"` that gates the popup; +// emitState({isActive: true}) must hide it, false must show it again. +describe("NibbleSurface bridge state gating", () => { + beforeEach(() => { + selection.isVisible.value = false; + selection.text.value = ""; + selection.rect.value = null; + selection.contextText.value = ""; + selection.clear.mockClear(); + }); + + function mountSurface() { + return mount(NibbleSurface, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + stubs: { SelectionPopup: true }, + }, + }); + } + + it("renders SelectionPopup when there is a visible selection and modal is closed", async () => { + selection.isVisible.value = true; + selection.text.value = "hi"; + selection.rect.value = new DOMRect(0, 0, 10, 10); + + const wrapper = mountSurface(); + await nextTick(); + + expect(wrapper.findComponent({ name: "SelectionPopup" }).exists()).toBe( + true + ); + + wrapper.unmount(); + }); + + it("hides SelectionPopup when emitState({isActive:true}) fires", async () => { + selection.isVisible.value = true; + selection.text.value = "hi"; + selection.rect.value = new DOMRect(0, 0, 10, 10); + + const wrapper = mountSurface(); + await nextTick(); + expect(wrapper.findComponent({ name: "SelectionPopup" }).exists()).toBe( + true + ); + + emitState({ isActive: true }); + await nextTick(); + expect(wrapper.findComponent({ name: "SelectionPopup" }).exists()).toBe( + false + ); + + wrapper.unmount(); + }); + + it("shows SelectionPopup again when emitState({isActive:false}) fires", async () => { + selection.isVisible.value = true; + selection.text.value = "hi"; + selection.rect.value = new DOMRect(0, 0, 10, 10); + + const wrapper = mountSurface(); + await nextTick(); + + emitState({ isActive: true }); + await nextTick(); + expect(wrapper.findComponent({ name: "SelectionPopup" }).exists()).toBe( + false + ); + + emitState({ isActive: false }); + await nextTick(); + expect(wrapper.findComponent({ name: "SelectionPopup" }).exists()).toBe( + true + ); + + wrapper.unmount(); + }); + + it("never renders SelectionPopup if there is no visible selection, regardless of modal state", async () => { + const wrapper = mountSurface(); + await nextTick(); + + expect(wrapper.findComponent({ name: "SelectionPopup" }).exists()).toBe( + false + ); + + emitState({ isActive: true }); + await nextTick(); + expect(wrapper.findComponent({ name: "SelectionPopup" }).exists()).toBe( + false + ); + + emitState({ isActive: false }); + await nextTick(); + expect(wrapper.findComponent({ name: "SelectionPopup" }).exists()).toBe( + false + ); + + wrapper.unmount(); + }); + + it("unsubscribes from the bridge on unmount (no leak across re-mounts)", async () => { + selection.isVisible.value = true; + selection.text.value = "hi"; + selection.rect.value = new DOMRect(0, 0, 10, 10); + + const wrapperA = mountSurface(); + await nextTick(); + wrapperA.unmount(); + + // After unmount, future state events must not affect a re-mounted instance. + const wrapperB = mountSurface(); + await nextTick(); + emitState({ isActive: false }); + await nextTick(); + + expect( + wrapperB.findComponent({ name: "SelectionPopup" }).exists() + ).toBe(true); + + wrapperB.unmount(); + }); +}); diff --git a/tests/route-params.test.ts b/tests/route-params.test.ts new file mode 100644 index 0000000..38e58d3 --- /dev/null +++ b/tests/route-params.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { + encodeRouteParams, + decodeRouteParams, +} from "../src/console-crane/route-params"; + +// Regression test for the documented `btoa` / Latin1-only crash. Anything that +// can appear inside a translation card (Persian, CJK, emoji, accented Latin) +// must round-trip cleanly without InvalidCharacterError. See the comment at +// the top of src/console-crane/route-params.ts for the original incident. +describe("route-params encode/decode round-trip", () => { + const cases: Array<[string, any]> = [ + ["ASCII", { word: "hello", context: "world" }], + ["accented Latin", { word: "café", context: "déjà vu" }], + ["Persian", { word: "سلام", context: "این یک متن است" }], + [ + "Chinese", + { word: "你好", context: "这是一段上下文" }, + ], + ["Japanese", { word: "こんにちは", context: "テスト" }], + ["emoji", { word: "🐢", context: "👋🏽 hi" }], + ["mixed", { word: "café 🐢 سلام 你好", context: "mixed" }], + ]; + + it.each(cases)("round-trips %s", (_label, value) => { + const encoded = encodeRouteParams(value); + expect(typeof encoded).toBe("string"); + expect(decodeRouteParams(encoded)).toEqual(value); + }); + + it("never throws InvalidCharacterError on non-Latin1 input", () => { + expect(() => + encodeRouteParams({ word: "آزمون", context: "💯 测试" }) + ).not.toThrow(); + }); + + it("round-trips undefined params via the empty string", () => { + // toggleConsoleCrane(page) calls encodeRouteParams without params for + // pages like "empty" / "settings". JSON.stringify(undefined) returns + // undefined (not "undefined"), so encode is a no-op and decode returns + // undefined back rather than throwing on JSON.parse(""). + const encoded = encodeRouteParams(undefined); + expect(encoded).toBe(""); + expect(decodeRouteParams(encoded)).toBeUndefined(); + }); + + it("decodes an empty string to undefined without throwing", () => { + expect(() => decodeRouteParams("")).not.toThrow(); + expect(decodeRouteParams("")).toBeUndefined(); + }); +}); diff --git a/tests/selection-popup.test.ts b/tests/selection-popup.test.ts new file mode 100644 index 0000000..b8eeddb --- /dev/null +++ b/tests/selection-popup.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; + +// SelectionPopup pulls in TranslateService → @modular-rest/client → mock the +// network layer so a stray click in another test can't fire a real request. +vi.mock("@modular-rest/client", () => ({ + functionProvider: { run: vi.fn() }, +})); + +import SelectionPopup from "../src/nibble/components/SelectionPopup.vue"; + +// Regression test for the "popup deselects page text and unmounts mid-click" +// bug noted in CLAUDE.md. The fix relies on `@mousedown.prevent.stop` on the +// root element. Both modifiers must be in place: `.prevent` keeps the browser +// from clearing the user's selection, and `.stop` keeps the document-level +// mousedown listener (used by useTextSelection to detect clicks outside the +// selection) from firing. +describe("SelectionPopup root mousedown handling", () => { + let parent: HTMLElement; + + beforeEach(() => { + parent = document.createElement("div"); + document.body.appendChild(parent); + }); + + function mountPopup() { + return mount(SelectionPopup, { + attachTo: parent, + props: { + text: "hello", + context: "context paragraph", + rect: new DOMRect(100, 100, 50, 20), + }, + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + }, + }); + } + + it("calls preventDefault on root mousedown (.prevent modifier)", () => { + const wrapper = mountPopup(); + + const root = wrapper.find(".nibble-popup").element as HTMLElement; + const ev = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + }); + root.dispatchEvent(ev); + + expect(ev.defaultPrevented).toBe(true); + wrapper.unmount(); + }); + + it("stops mousedown from bubbling to document (.stop modifier)", () => { + const wrapper = mountPopup(); + + const documentListener = vi.fn(); + document.addEventListener("mousedown", documentListener); + + const root = wrapper.find(".nibble-popup").element as HTMLElement; + root.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, cancelable: true }) + ); + + expect(documentListener).not.toHaveBeenCalled(); + + document.removeEventListener("mousedown", documentListener); + wrapper.unmount(); + }); + + it("renders the icon button in initial mode", () => { + const wrapper = mountPopup(); + expect(wrapper.find(".nibble-icon-btn").exists()).toBe(true); + expect(wrapper.find(".nibble-card").exists()).toBe(false); + wrapper.unmount(); + }); +}); diff --git a/tests/settings-host.test.ts b/tests/settings-host.test.ts new file mode 100644 index 0000000..390fb83 --- /dev/null +++ b/tests/settings-host.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { setActivePinia, createPinia } from "pinia"; + +import { useSettingsStore } from "../src/common/store/settings"; + +// Per-host Nibble toggle. The store hashes hosts via a private normalizeHost +// (lowercase + leading-`www.` strip) so equivalent hosts collapse to a single +// entry. setNibbleDisabledForHost also fires syncSettingsToBackground -> the +// chrome.runtime.sendMessage shim from tests/setup.ts swallows the call. +describe("settings store: per-host Nibble toggle", () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it("normalizes case when checking", () => { + const store = useSettingsStore(); + store.setNibbleDisabledForHost("Example.COM", true); + + expect(store.isNibbleDisabledForHost("example.com")).toBe(true); + expect(store.isNibbleDisabledForHost("EXAMPLE.com")).toBe(true); + }); + + it("strips a leading www.", () => { + const store = useSettingsStore(); + store.setNibbleDisabledForHost("www.example.com", true); + + expect(store.isNibbleDisabledForHost("example.com")).toBe(true); + expect(store.isNibbleDisabledForHost("www.example.com")).toBe(true); + expect(store.nibbleDisabledDomains).toEqual(["example.com"]); + }); + + it("does not duplicate an already-disabled host", () => { + const store = useSettingsStore(); + store.setNibbleDisabledForHost("example.com", true); + store.setNibbleDisabledForHost("www.example.com", true); + store.setNibbleDisabledForHost("EXAMPLE.com", true); + + expect(store.nibbleDisabledDomains).toEqual(["example.com"]); + }); + + it("removes a host when disabled is set to false", () => { + const store = useSettingsStore(); + store.setNibbleDisabledForHost("example.com", true); + store.setNibbleDisabledForHost("other.com", true); + expect(store.nibbleDisabledDomains).toEqual(["example.com", "other.com"]); + + store.setNibbleDisabledForHost("www.example.com", false); + expect(store.nibbleDisabledDomains).toEqual(["other.com"]); + expect(store.isNibbleDisabledForHost("example.com")).toBe(false); + }); + + it("ignores a re-enable of an already-enabled host", () => { + const store = useSettingsStore(); + expect(store.nibbleDisabledDomains).toEqual([]); + store.setNibbleDisabledForHost("example.com", false); + expect(store.nibbleDisabledDomains).toEqual([]); + }); + + it("treats subdomains as distinct hosts", () => { + const store = useSettingsStore(); + store.setNibbleDisabledForHost("blog.example.com", true); + expect(store.isNibbleDisabledForHost("example.com")).toBe(false); + expect(store.isNibbleDisabledForHost("blog.example.com")).toBe(true); + }); + + it("invokes chrome.runtime.sendMessage when a host is toggled", () => { + const store = useSettingsStore(); + const sendMessage = (globalThis as any).chrome.runtime + .sendMessage as ReturnType; + sendMessage.mockClear(); + + store.setNibbleDisabledForHost("example.com", true); + + expect(sendMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..5599bed --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,59 @@ +import { vi } from "vitest"; + +// Hand-rolled chrome.* shim. The production code touches a small surface +// of chrome APIs at module load (settings store registers onMessage / +// onChanged listeners) plus on-demand calls (sendMessage, tabs.query, +// storage.local.get/set, i18n.getUILanguage). Keep this minimal — tests +// override individual fns with vi.fn() when they need specific behaviour. +function makeChromeShim() { + return { + runtime: { + sendMessage: vi.fn( + (_message: any, callback?: (response: any) => void) => { + if (callback) callback({}); + return Promise.resolve({}); + } + ), + onMessage: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + lastError: undefined as { message?: string } | undefined, + getURL: vi.fn((p: string) => `chrome-extension://test${p}`), + }, + storage: { + local: { + get: vi.fn((_key: any, callback: (data: any) => void) => callback({})), + set: vi.fn( + (_obj: any, callback?: () => void) => callback && callback() + ), + }, + onChanged: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + }, + tabs: { + query: vi.fn((_q: any, callback: (tabs: any[]) => void) => callback([])), + sendMessage: vi.fn(), + }, + i18n: { + getUILanguage: vi.fn(() => "en-US"), + }, + }; +} + +(globalThis as any).chrome = makeChromeShim(); + +// Production code uses analytics + dotenv-injected mixpanel token. In tests +// we never want network traffic, so neutralize the module entirely. +vi.mock("mixpanel-browser", () => ({ + default: { + init: vi.fn(), + register: vi.fn(), + track: vi.fn(), + }, +})); + +// Silence noisy console.log from src/common/helper/log.ts during tests. +vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/tests/translate-card.test.ts b/tests/translate-card.test.ts new file mode 100644 index 0000000..9d1fd8e --- /dev/null +++ b/tests/translate-card.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mount, flushPromises, type VueWrapper } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; +import { defineComponent } from "vue"; + +// Stub WordDetailModule at module-resolve time. The real module pulls in +// modular-rest, the translation service, and the auth plugin chain — none of +// which we want to evaluate while testing the popup's input shell. The stub +// re-emits prop changes so we can assert the parent passed the right word, +// and exposes a `loading` event so we can drive the parent's spinner state. +vi.mock("../src/console-crane/modules/word-detail/index.vue", () => ({ + default: defineComponent({ + name: "WordDetailModule", + props: { word: { type: String, required: true } }, + emits: ["loading"], + template: '
', + }), +})); + +import TranslateCard from "../src/popup/components/TranslateCard.vue"; + +// CLAUDE.md verification checklist for the popup translate input: +// - input is auto-focused on open +// - submitting renders the detailed result inline +// - re-translating a different word resets the result +// - the button shows a spinner while pending +// - logged-out users see "Login to save this phrase" / logged-in get the +// bundle picker — that's WordDetailModule's responsibility, not this +// component's, so we cover it elsewhere. +describe("TranslateCard (popup translate input)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function mountCard(): VueWrapper { + return mount(TranslateCard, { + attachTo: document.body, + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + }, + }); + } + + it("auto-focuses the input on mount", async () => { + const wrapper = mountCard(); + await flushPromises(); + + const input = wrapper.find("input").element; + expect(document.activeElement).toBe(input); + + wrapper.unmount(); + }); + + it("disables the submit button when the input is empty", () => { + const wrapper = mountCard(); + expect( + wrapper.find('button[type="submit"]').attributes("disabled") + ).toBeDefined(); + wrapper.unmount(); + }); + + it("disables the submit button on whitespace-only input", async () => { + const wrapper = mountCard(); + await wrapper.find("input").setValue(" \t "); + expect( + wrapper.find('button[type="submit"]').attributes("disabled") + ).toBeDefined(); + wrapper.unmount(); + }); + + it("enables the submit button once meaningful text is entered", async () => { + const wrapper = mountCard(); + await wrapper.find("input").setValue("hello"); + expect( + wrapper.find('button[type="submit"]').attributes("disabled") + ).toBeUndefined(); + wrapper.unmount(); + }); + + it("renders WordDetailModule with the typed word on submit", async () => { + const wrapper = mountCard(); + await wrapper.find("input").setValue("hello"); + await wrapper.find("form").trigger("submit"); + + const stub = wrapper.find(".word-detail-stub"); + expect(stub.exists()).toBe(true); + expect(stub.attributes("data-word")).toBe("hello"); + + wrapper.unmount(); + }); + + it("trims surrounding whitespace before passing the word along", async () => { + const wrapper = mountCard(); + await wrapper.find("input").setValue(" hello "); + await wrapper.find("form").trigger("submit"); + + expect(wrapper.find(".word-detail-stub").attributes("data-word")).toBe( + "hello" + ); + wrapper.unmount(); + }); + + it("updates the result when a different word is submitted", async () => { + const wrapper = mountCard(); + + await wrapper.find("input").setValue("hello"); + await wrapper.find("form").trigger("submit"); + // Clear the loading flag so the next submit isn't blocked by `loading`. + wrapper.findComponent({ name: "WordDetailModule" }).vm.$emit("loading", false); + await flushPromises(); + + await wrapper.find("input").setValue("world"); + await wrapper.find("form").trigger("submit"); + + expect(wrapper.find(".word-detail-stub").attributes("data-word")).toBe( + "world" + ); + wrapper.unmount(); + }); + + it("shows a spinner and 'Translating…' label after submit", async () => { + const wrapper = mountCard(); + await wrapper.find("input").setValue("hi"); + await wrapper.find("form").trigger("submit"); + + const button = wrapper.find('button[type="submit"]'); + expect(button.text()).toContain("Translating"); + expect(button.find("svg.animate-spin").exists()).toBe(true); + wrapper.unmount(); + }); + + it("clears the spinner when WordDetailModule emits loading=false", async () => { + const wrapper = mountCard(); + await wrapper.find("input").setValue("hi"); + await wrapper.find("form").trigger("submit"); + + wrapper.findComponent({ name: "WordDetailModule" }).vm.$emit("loading", false); + await flushPromises(); + + const button = wrapper.find('button[type="submit"]'); + expect(button.text()).not.toContain("Translating"); + expect(button.find("svg.animate-spin").exists()).toBe(false); + wrapper.unmount(); + }); + + it("disables submit while a translation is pending", async () => { + const wrapper = mountCard(); + await wrapper.find("input").setValue("hi"); + await wrapper.find("form").trigger("submit"); + + expect( + wrapper.find('button[type="submit"]').attributes("disabled") + ).toBeDefined(); + wrapper.unmount(); + }); + + it("ignores re-submitting the same word (no double-fetch on enter mash)", async () => { + const wrapper = mountCard(); + await wrapper.find("input").setValue("hi"); + await wrapper.find("form").trigger("submit"); + + const stub = wrapper.findComponent({ name: "WordDetailModule" }); + stub.vm.$emit("loading", false); + await flushPromises(); + + // Resubmit identical text — TranslateCard's submit() short-circuits. + // Spinner must not reappear. + await wrapper.find("form").trigger("submit"); + expect(wrapper.find('button[type="submit"]').text()).not.toContain( + "Translating" + ); + wrapper.unmount(); + }); +}); diff --git a/tests/translate.service.test.ts b/tests/translate.service.test.ts new file mode 100644 index 0000000..19c4266 --- /dev/null +++ b/tests/translate.service.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { setActivePinia, createPinia } from "pinia"; + +vi.mock("@modular-rest/client", () => ({ + functionProvider: { + run: vi.fn(), + }, +})); + +import { TranslateService } from "../src/common/services/translate.service"; +import { useSettingsStore } from "../src/common/store/settings"; +import { functionProvider } from "@modular-rest/client"; + +const runMock = functionProvider.run as unknown as ReturnType; + +// The cache key is `${type}_${languageTitle}_${text}_${context}` so all four +// dimensions are exercised. Eviction is tested with vi fake timers because +// CACHE_DURATION is 24h and there is no public clear() on the singleton. +describe("TranslateService cache", () => { + let svc: TranslateService; + + beforeEach(() => { + setActivePinia(createPinia()); + useSettingsStore().language = "en"; + + // The singleton is shared across imports; reset the private cache so + // tests don't leak state into each other. + (TranslateService.instance as any).translationCache = {}; + svc = TranslateService.instance; + + runMock.mockReset(); + runMock.mockResolvedValue("translated"); + + // fetchSimpleTranslation logs to console.error on failure paths; + // silence to keep test output clean. + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("returns the cached result for identical (text, context, type, lang)", async () => { + const a = await svc.fetchSimpleTranslation("hello", "ctx"); + const b = await svc.fetchSimpleTranslation("hello", "ctx"); + + expect(a).toBe("translated"); + expect(b).toBe("translated"); + expect(runMock).toHaveBeenCalledTimes(1); + }); + + it("misses cache when context differs", async () => { + await svc.fetchSimpleTranslation("hello", "ctxA"); + await svc.fetchSimpleTranslation("hello", "ctxB"); + expect(runMock).toHaveBeenCalledTimes(2); + }); + + it("misses cache when target language changes", async () => { + await svc.fetchSimpleTranslation("hello", "ctx"); + useSettingsStore().language = "fa"; + await svc.fetchSimpleTranslation("hello", "ctx"); + expect(runMock).toHaveBeenCalledTimes(2); + }); + + it("keys simple and detailed translations separately", async () => { + runMock.mockResolvedValue({ phrase: "", context: "" }); + await svc.fetchSimpleTranslation("hello", "ctx"); + await svc.fetchDetailedTranslation("hello", "ctx"); + expect(runMock).toHaveBeenCalledTimes(2); + }); + + it("evicts entries older than the 24h CACHE_DURATION", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + await svc.fetchSimpleTranslation("hello", "ctx"); + expect(runMock).toHaveBeenCalledTimes(1); + + vi.setSystemTime(new Date("2026-01-01T23:59:00Z")); + await svc.fetchSimpleTranslation("hello", "ctx"); + expect(runMock).toHaveBeenCalledTimes(1); + + vi.setSystemTime(new Date("2026-01-02T00:01:00Z")); + await svc.fetchSimpleTranslation("hello", "ctx"); + expect(runMock).toHaveBeenCalledTimes(2); + }); + + it("does not cache failed requests", async () => { + runMock.mockRejectedValueOnce(new Error("network")); + await expect(svc.fetchSimpleTranslation("hello", "ctx")).rejects.toThrow( + "network" + ); + + runMock.mockResolvedValue("ok"); + const result = await svc.fetchSimpleTranslation("hello", "ctx"); + expect(result).toBe("ok"); + expect(runMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ffc8dda --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + // The project's postcss.config.js targets webpack and uses a custom + // rem→px plugin that Vite's loader rejects. Tests don't import CSS, so + // an inline empty postcss config bypasses the file. + css: { + postcss: { + plugins: [], + }, + }, + test: { + environment: "happy-dom", + setupFiles: ["./tests/setup.ts"], + include: ["tests/**/*.test.ts"], + // E2E specs run under Playwright, not Vitest. The .spec.ts suffix + + // tests/e2e/ directory keeps the two suites cleanly separated. + exclude: ["node_modules/**", "tests/e2e/**", "**/*.spec.ts"], + globals: false, + }, +}); diff --git a/webpack.config.js b/webpack.config.js index ce09181..1b65008 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -75,6 +75,7 @@ module.exports = { entry: { main: "./src/main.ts", nibble: "./src/nibble.ts", + "console-crane": "./src/console-crane.ts", background: "./src/background.ts", popup: "./src/popup.ts", }, diff --git a/yarn.lock b/yarn.lock index f8b8c78..bfe05ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -205,6 +205,121 @@ dependencies: "@iconify/utils" "^2.1.12" +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + "@gar/promise-retry@^1.0.0", "@gar/promise-retry@^1.0.2": version "1.0.3" resolved "https://registry.yarnpkg.com/@gar/promise-retry/-/promise-retry-1.0.3.tgz#65e726428e794bc4453948e0a41e6de4215ce8b0" @@ -244,6 +359,18 @@ local-pkg "^1.0.0" mlly "^1.7.4" +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + "@isaacs/fs-minipass@^4.0.0": version "4.0.1" resolved "https://registry.yarnpkg.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz#2d59ae3ab4b38fb4270bfa23d30f8e2e86c7fe32" @@ -577,6 +704,28 @@ dependencies: "@octokit/openapi-types" "^27.0.0" +"@one-ini/wasm@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323" + integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw== + +"@pinia/testing@^1": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@pinia/testing/-/testing-1.0.3.tgz#62e0813a7a8ac735505422bb7a4e38eb86f815dc" + integrity sha512-g+qR49GNdI1Z8rZxKrQC3GN+LfnGTNf5Kk8Nz5Cz6mIGva5WRS+ffPXQfzhA0nu6TveWzPNYTjGl4nJqd3Cu9Q== + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@playwright/test@^1.49": + version "1.59.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.59.1.tgz#5c4d38eac84a61527af466602ae20277685a02d6" + integrity sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg== + dependencies: + playwright "1.59.1" + "@pnpm/config.env-replace@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz#ab29da53df41e8948a00f2433f085f54de8b3a4c" @@ -603,11 +752,146 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== +"@rollup/rollup-android-arm-eabi@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz#a19c645c375158cd5c50a344106f0fa18eb821c4" + integrity sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw== + +"@rollup/rollup-android-arm64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz#1af19aa9d3ad6d00df2681f59cfcb8bf7499576b" + integrity sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg== + +"@rollup/rollup-darwin-arm64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz#3b8463e03ba2a393453fea70e7d907379c27b649" + integrity sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA== + +"@rollup/rollup-darwin-x64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz#28da23d69fe117f5f0ff330a8549e51bd09f1b6a" + integrity sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g== + +"@rollup/rollup-freebsd-arm64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz#94bacac3190f621de1355922b599f3817786044c" + integrity sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw== + +"@rollup/rollup-freebsd-x64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz#8a0094f533b9fda160b5c90ad9e0c78fca341788" + integrity sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ== + +"@rollup/rollup-linux-arm-gnueabihf@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz#3b7e901a555c7245c87f7440979bee0a1ec882bb" + integrity sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg== + +"@rollup/rollup-linux-arm-musleabihf@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz#ee9a09b72e8ad764cfd6188b32ff1de528ff7ebe" + integrity sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw== + +"@rollup/rollup-linux-arm64-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz#ba483f4aca9be141171d086dbd01ada6ab03b58d" + integrity sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg== + +"@rollup/rollup-linux-arm64-musl@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz#17b595b790e6df68e91c5d02526fc832a985ce4f" + integrity sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA== + +"@rollup/rollup-linux-loong64-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz#551718714075a2bfb36a2813c466e3a0e9d56abf" + integrity sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A== + +"@rollup/rollup-linux-loong64-musl@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz#ba156ed1243447a3d710972001d5dcfe3827ff3d" + integrity sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q== + +"@rollup/rollup-linux-ppc64-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz#6a957a709b86ac62ef68e597ac03dbd4336782b1" + integrity sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw== + +"@rollup/rollup-linux-ppc64-musl@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz#ca4176b4ad53f3edee3b4bfa6f9ef48ff38f167b" + integrity sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ== + +"@rollup/rollup-linux-riscv64-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz#4e6b08f72ebeafdb41f3ec433bd228ba8573473b" + integrity sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A== + +"@rollup/rollup-linux-riscv64-musl@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz#a0b8b8580c7680c8086cb3226527e5472253b895" + integrity sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ== + +"@rollup/rollup-linux-s390x-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz#79fe15b92ce0bae2b609cf26dd158cd3e2b73634" + integrity sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA== + +"@rollup/rollup-linux-x64-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz#6aa8302fa45fd3cbbc510ccd223c9c37bf67e53f" + integrity sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ== + +"@rollup/rollup-linux-x64-musl@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz#0c1a5e9799f80c47a66f2c3a5f1a280f38356047" + integrity sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw== + +"@rollup/rollup-openbsd-x64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz#5f07c863e74fd428794f1dc5749f321b661d1f17" + integrity sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg== + +"@rollup/rollup-openharmony-arm64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz#8e0d71324be0f423428b12b25a2eb8ea8e0a7833" + integrity sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q== + +"@rollup/rollup-win32-arm64-msvc@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz#a553fdf90a785ace6d7501eed6241c468b088999" + integrity sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ== + +"@rollup/rollup-win32-ia32-msvc@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz#0fb04f0a88027fbfd323e25a446debce4773868c" + integrity sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg== + +"@rollup/rollup-win32-x64-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz#aaa9e36dbdc0f0e397e5966dcce1b4285354ede2" + integrity sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA== + +"@rollup/rollup-win32-x64-msvc@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz#3418dcf1388f2abd6b0ccc08fe1ef205ae76d696" + integrity sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA== + "@sec-ant/readable-stream@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c" integrity sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg== +"@semantic-release/changelog@^6.0.3": + version "6.0.3" + resolved "https://registry.yarnpkg.com/@semantic-release/changelog/-/changelog-6.0.3.tgz#6195630ecbeccad174461de727d5f975abc23eeb" + integrity sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag== + dependencies: + "@semantic-release/error" "^3.0.0" + aggregate-error "^3.0.0" + fs-extra "^11.0.0" + lodash "^4.17.4" + "@semantic-release/commit-analyzer@^13.0.1": version "13.0.1" resolved "https://registry.yarnpkg.com/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.1.tgz#d84b599c3fef623ccc01f0cc2025eb56a57d8feb" @@ -844,7 +1128,7 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@^1.0.6": +"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== @@ -893,6 +1177,70 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== +"@vitejs/plugin-vue@^5": + version "5.2.4" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz#9e8a512eb174bfc2a333ba959bbf9de428d89ad8" + integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA== + +"@vitest/expect@2.1.9": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-2.1.9.tgz#b566ea20d58ea6578d8dc37040d6c1a47ebe5ff8" + integrity sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw== + dependencies: + "@vitest/spy" "2.1.9" + "@vitest/utils" "2.1.9" + chai "^5.1.2" + tinyrainbow "^1.2.0" + +"@vitest/mocker@2.1.9": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-2.1.9.tgz#36243b27351ca8f4d0bbc4ef91594ffd2dc25ef5" + integrity sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg== + dependencies: + "@vitest/spy" "2.1.9" + estree-walker "^3.0.3" + magic-string "^0.30.12" + +"@vitest/pretty-format@2.1.9", "@vitest/pretty-format@^2.1.9": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz#434ff2f7611689f9ce70cd7d567eceb883653fdf" + integrity sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ== + dependencies: + tinyrainbow "^1.2.0" + +"@vitest/runner@2.1.9": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-2.1.9.tgz#cc18148d2d797fd1fd5908d1f1851d01459be2f6" + integrity sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g== + dependencies: + "@vitest/utils" "2.1.9" + pathe "^1.1.2" + +"@vitest/snapshot@2.1.9": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.1.9.tgz#24260b93f798afb102e2dcbd7e61c6dfa118df91" + integrity sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ== + dependencies: + "@vitest/pretty-format" "2.1.9" + magic-string "^0.30.12" + pathe "^1.1.2" + +"@vitest/spy@2.1.9": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-2.1.9.tgz#cb28538c5039d09818b8bfa8edb4043c94727c60" + integrity sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ== + dependencies: + tinyspy "^3.0.2" + +"@vitest/utils@2.1.9": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-2.1.9.tgz#4f2486de8a54acf7ecbf2c5c24ad7994a680a6c1" + integrity sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ== + dependencies: + "@vitest/pretty-format" "2.1.9" + loupe "^3.1.2" + tinyrainbow "^1.2.0" + "@vue/compiler-core@3.5.17": version "3.5.17" resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.17.tgz#23d291bd01b863da3ef2e26e7db84d8e01a9b4c5" @@ -1085,6 +1433,14 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.33.tgz#b41070039e91d2921edb4c38cbcc80f498a24f3a" integrity sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ== +"@vue/test-utils@^2": + version "2.4.10" + resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.4.10.tgz#f3b006e03918e66b5df1f2a6f7f5200663b525d3" + integrity sha512-SmoZ5EA1kYiAFs9NkYdiFFQF+cSnUwnvlYEbY+DogWQZUiqOm/Y29eSbc5T6yi75SgSF9863SBeXniIEoPajCA== + dependencies: + js-beautify "^1.14.9" + vue-component-type-helpers "^3.0.0" + "@vueuse/head@^0.9.7": version "0.9.8" resolved "https://registry.yarnpkg.com/@vueuse/head/-/head-0.9.8.tgz#0216fb44fa832ec710862cc60351dbb3e2c6b84c" @@ -1268,6 +1624,11 @@ resolved "https://registry.yarnpkg.com/@zhead/schema/-/schema-0.8.5.tgz#17f5c6be3b587a938f76d93637a210c0d05a9069" integrity sha512-1S3Otr2zpl1zwP72dNseVXQNG9tnTQ6hHUEUYwINvBjRj6bHcUwdE+Itc9OLxnGAJT/7p8P7GHGo5sshXJNJsA== +abbrev@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" + integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== + abbrev@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-4.0.0.tgz#ec933f0e27b6cd60e89b5c6b2a304af42209bb05" @@ -1379,7 +1740,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^6.2.1: +ansi-styles@^6.1.0, ansi-styles@^6.2.1: version "6.2.3" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== @@ -1427,6 +1788,11 @@ array-ify@^1.0.0: resolved "https://registry.yarnpkg.com/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" integrity sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng== +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -1461,6 +1827,11 @@ axios@^1.6.7: form-data "^4.0.5" proxy-from-env "^2.1.0" +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + balanced-match@^4.0.2: version "4.0.4" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" @@ -1517,6 +1888,13 @@ bottleneck@^2.15.3: resolved "https://registry.yarnpkg.com/bottleneck/-/bottleneck-2.19.5.tgz#5df0b90f59fd47656ebe63c78a98419205cadd91" integrity sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw== +brace-expansion@^2.0.2: + version "2.1.0" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.0.tgz#4f41a41190216ee36067ec381526fe9539c4f0ae" + integrity sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w== + dependencies: + balanced-match "^1.0.0" + brace-expansion@^5.0.5: version "5.0.5" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" @@ -1552,6 +1930,11 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + cacache@^20.0.0, cacache@^20.0.1, cacache@^20.0.4: version "20.0.4" resolved "https://registry.yarnpkg.com/cacache/-/cacache-20.0.4.tgz#9b547dc3db0c1f87cba6dbbff91fb17181b4bbb1" @@ -1609,6 +1992,17 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001782, caniuse-lite@^1.0.30001787: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz#dfb93d85c40ad380c57123e72e10f3c575786b51" integrity sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ== +chai@^5.1.2: + version "5.3.3" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + chalk@^2.3.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -1636,6 +2030,11 @@ char-regex@^1.0.2: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== +check-error@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.3.tgz#2427361117b70cca8dc89680ead32b157019caf5" + integrity sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA== + "chokidar@>=3.0.0 <4.0.0", chokidar@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -1777,6 +2176,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + commander@^12.1.0: version "12.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" @@ -1820,7 +2224,7 @@ confbox@^0.2.4: resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.2.4.tgz#592e7be71f882a4a874e3c88f0ac1ef6f7da1ce5" integrity sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ== -config-chain@^1.1.11: +config-chain@^1.1.11, config-chain@^1.1.13: version "1.1.13" resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== @@ -2068,13 +2472,18 @@ debounce@^1.2.1: resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== -debug@4, debug@^4.0.0, debug@^4.3.4, debug@^4.4.0, debug@^4.4.3: +debug@4, debug@^4.0.0, debug@^4.3.4, debug@^4.3.7, debug@^4.4.0, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -2179,6 +2588,21 @@ duplexer2@~0.1.0: dependencies: readable-stream "^2.0.2" +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +editorconfig@^1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-1.0.7.tgz#8d6e178aeb507c206d65e1804c1d7510d110d434" + integrity sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw== + dependencies: + "@one-ini/wasm" "0.1.1" + commander "^10.0.0" + minimatch "^9.0.1" + semver "^7.5.3" + electron-to-chromium@^1.5.328: version "1.5.348" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.348.tgz#8031cb2cc3a60cc798c94d4f44bfc174d015e844" @@ -2199,6 +2623,11 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + emojilib@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/emojilib/-/emojilib-2.4.0.tgz#ac518a8bb0d5f76dda57289ccb2fdf9d39ae721e" @@ -2282,7 +2711,7 @@ es-errors@^1.3.0: resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== -es-module-lexer@^1.2.1: +es-module-lexer@^1.2.1, es-module-lexer@^1.5.4: version "1.7.0" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== @@ -2304,6 +2733,35 @@ es-set-tostringtag@^2.1.0: has-tostringtag "^1.0.2" hasown "^2.0.2" +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" @@ -2354,6 +2812,13 @@ estree-walker@^2.0.2: resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + eventemitter2@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-5.0.1.tgz#6197a095d5fb6b57e8942f6fd7eaad63a09c9452" @@ -2417,6 +2882,11 @@ execa@^9.0.0: strip-final-newline "^4.0.0" yoctocolors "^2.1.1" +expect-type@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68" + integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== + exponential-backoff@^3.1.1: version "3.1.3" resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz#51cf92c1c0493c766053f9d3abee4434c244d2f6" @@ -2539,6 +3009,14 @@ follow-redirects@^1.15.0, follow-redirects@^1.15.11: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + form-data@^4.0.0, form-data@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" @@ -2579,7 +3057,12 @@ fs-minipass@^3.0.0, fs-minipass@^3.0.3: dependencies: minipass "^7.0.3" -fsevents@~2.3.2: +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== @@ -2682,6 +3165,18 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== +glob@^10.4.2: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + glob@^13.0.0, glob@^13.0.6: version "13.0.6" resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d" @@ -2734,6 +3229,15 @@ handlebars@^4.7.7: optionalDependencies: uglify-js "^3.1.4" +happy-dom@^15: + version "15.11.7" + resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-15.11.7.tgz#db9854f11e5dd3fd4ab20cbbcfdf7bd9e17cd971" + integrity sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg== + dependencies: + entities "^4.5.0" + webidl-conversions "^7.0.0" + whatwg-mimetype "^3.0.0" + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -3092,6 +3596,15 @@ issue-parser@^7.0.0: lodash.isstring "^4.0.1" lodash.uniqby "^4.7.0" +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + java-properties@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/java-properties/-/java-properties-1.0.2.tgz#ccd1fa73907438a5b5c38982269d0e771fe78211" @@ -3111,6 +3624,22 @@ jiti@^1.21.7: resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9" integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A== +js-beautify@^1.14.9: + version "1.15.4" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.4.tgz#f579f977ed4c930cef73af8f98f3f0a608acd51e" + integrity sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA== + dependencies: + config-chain "^1.1.13" + editorconfig "^1.0.4" + glob "^10.4.2" + js-cookie "^3.0.5" + nopt "^7.2.1" + +js-cookie@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" + integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3446,7 +3975,12 @@ lodash@^4.17.4: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== -lru-cache@^10.0.1: +loupe@^3.1.0, loupe@^3.1.2: + version "3.2.1" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== + +lru-cache@^10.0.1, lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== @@ -3456,7 +3990,7 @@ lru-cache@^11.0.0, lru-cache@^11.1.0, lru-cache@^11.2.1: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.3.5.tgz#29047d348c0b2793e3112a01c739bb7c6d855637" integrity sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw== -magic-string@^0.30.17, magic-string@^0.30.21: +magic-string@^0.30.12, magic-string@^0.30.17, magic-string@^0.30.21: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== @@ -3575,6 +4109,13 @@ minimatch@^10.0.3, minimatch@^10.1.1, minimatch@^10.2.2, minimatch@^10.2.5: dependencies: brace-expansion "^5.0.5" +minimatch@^9.0.1, minimatch@^9.0.4: + version "9.0.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== + dependencies: + brace-expansion "^2.0.2" + minimist@^1.2.0, minimist@^1.2.5: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" @@ -3626,7 +4167,7 @@ minipass@^3.0.0: dependencies: yallist "^4.0.0" -minipass@^7.0.2, minipass@^7.0.3, minipass@^7.0.4, minipass@^7.1.2, minipass@^7.1.3: +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.2, minipass@^7.0.3, minipass@^7.0.4, minipass@^7.1.2, minipass@^7.1.3: version "7.1.3" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== @@ -3728,6 +4269,13 @@ node-releases@^2.0.36: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.38.tgz#791569b9e4424a044e12c3abfad418ed83ce9947" integrity sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw== +nopt@^7.2.1: + version "7.2.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7" + integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w== + dependencies: + abbrev "^2.0.0" + nopt@^9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/nopt/-/nopt-9.0.0.tgz#6bff0836b2964d24508b6b41b5a9a49c4f4a1f96" @@ -4053,6 +4601,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + package-manager-detector@^1.3.0: version "1.6.0" resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz#70d0cf0aa02c877eeaf66c4d984ede0be9130734" @@ -4176,6 +4729,14 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-scurry@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85" @@ -4189,7 +4750,7 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -pathe@^1.1.0: +pathe@^1.1.0, pathe@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== @@ -4199,6 +4760,11 @@ pathe@^2.0.1, pathe@^2.0.3: resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== + perfect-debounce@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz#9c2e8bc30b169cc984a58b7d5b28049839591d2a" @@ -4314,6 +4880,20 @@ pkg-types@^2.3.0: exsolve "^1.0.8" pathe "^2.0.3" +playwright-core@1.59.1: + version "1.59.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2" + integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg== + +playwright@1.59.1: + version "1.59.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a" + integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw== + dependencies: + playwright-core "1.59.1" + optionalDependencies: + fsevents "2.3.2" + postcss-attribute-case-insensitive@^5.0.2: version "5.0.2" resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz#03d761b24afc04c09e757e92ff53716ae8ea2741" @@ -4874,7 +5454,7 @@ postcss@^7.0.1: picocolors "^0.2.1" source-map "^0.6.1" -postcss@^8.4.47, postcss@^8.4.7, postcss@^8.5.10, postcss@^8.5.6: +postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.7, postcss@^8.5.10, postcss@^8.5.6: version "8.5.13" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.13.tgz#6cfaf647f2e7ef69850208eccd849e0d3f65d420" integrity sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag== @@ -5146,6 +5726,40 @@ rfdc@^1.4.1: resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== +rollup@^4.20.0: + version "4.60.2" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.60.2.tgz#ac23fe4bd530304cef9fa61e639d7098b6762cf4" + integrity sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.60.2" + "@rollup/rollup-android-arm64" "4.60.2" + "@rollup/rollup-darwin-arm64" "4.60.2" + "@rollup/rollup-darwin-x64" "4.60.2" + "@rollup/rollup-freebsd-arm64" "4.60.2" + "@rollup/rollup-freebsd-x64" "4.60.2" + "@rollup/rollup-linux-arm-gnueabihf" "4.60.2" + "@rollup/rollup-linux-arm-musleabihf" "4.60.2" + "@rollup/rollup-linux-arm64-gnu" "4.60.2" + "@rollup/rollup-linux-arm64-musl" "4.60.2" + "@rollup/rollup-linux-loong64-gnu" "4.60.2" + "@rollup/rollup-linux-loong64-musl" "4.60.2" + "@rollup/rollup-linux-ppc64-gnu" "4.60.2" + "@rollup/rollup-linux-ppc64-musl" "4.60.2" + "@rollup/rollup-linux-riscv64-gnu" "4.60.2" + "@rollup/rollup-linux-riscv64-musl" "4.60.2" + "@rollup/rollup-linux-s390x-gnu" "4.60.2" + "@rollup/rollup-linux-x64-gnu" "4.60.2" + "@rollup/rollup-linux-x64-musl" "4.60.2" + "@rollup/rollup-openbsd-x64" "4.60.2" + "@rollup/rollup-openharmony-arm64" "4.60.2" + "@rollup/rollup-win32-arm64-msvc" "4.60.2" + "@rollup/rollup-win32-ia32-msvc" "4.60.2" + "@rollup/rollup-win32-x64-gnu" "4.60.2" + "@rollup/rollup-win32-x64-msvc" "4.60.2" + fsevents "~2.3.2" + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -5288,6 +5902,11 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -5439,6 +6058,16 @@ stable@^0.1.8: resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^3.8.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + stopword@^1.0.0: version "1.0.11" resolved "https://registry.yarnpkg.com/stopword/-/stopword-1.0.11.tgz#2f9f36558bf1ad8c9e1197e572442e9b8814f153" @@ -5452,6 +6081,15 @@ stream-combiner2@~1.1.1: duplexer2 "~0.1.0" readable-stream "^2.0.2" +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -5461,6 +6099,15 @@ string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + string-width@^7.0.0, string-width@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc" @@ -5477,6 +6124,13 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -5484,7 +6138,7 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" -strip-ansi@^7.1.0: +strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== @@ -5756,6 +6410,16 @@ tiny-relative-date@^2.0.2: resolved "https://registry.yarnpkg.com/tiny-relative-date/-/tiny-relative-date-2.0.2.tgz#0c35c2a3ef87b80f311314918505aa86c2d44bc9" integrity sha512-rGxAbeL9z3J4pI2GtBEoFaavHdO4RKAU54hEuOef5kfx5aPqiQtbhYktMOTL5OA33db8BjsDcLXuNp+/v19PHw== +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + tinyexec@^1.0.1: version "1.1.2" resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.1.2.tgz#11feef204b706d4668ca4013db29f3bd64f5c4dc" @@ -5769,6 +6433,21 @@ tinyglobby@^0.2.11, tinyglobby@^0.2.12, tinyglobby@^0.2.14: fdir "^6.5.0" picomatch "^4.0.4" +tinypool@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== + +tinyrainbow@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz#5c57d2fc0fb3d1afd78465c33ca885d04f02abb5" + integrity sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ== + +tinyspy@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-3.0.2.tgz#86dd3cf3d737b15adcf17d7887c84a75201df20a" + integrity sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q== + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -5963,11 +6642,64 @@ validate-npm-package-name@^7.0.0, validate-npm-package-name@^7.0.2: resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz#e57c3d721a4c8bbff454a246e7f7da811559ea0d" integrity sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A== +vite-node@2.1.9: + version "2.1.9" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-2.1.9.tgz#549710f76a643f1c39ef34bdb5493a944e4f895f" + integrity sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA== + dependencies: + cac "^6.7.14" + debug "^4.3.7" + es-module-lexer "^1.5.4" + pathe "^1.1.2" + vite "^5.0.0" + +vite@^5.0.0: + version "5.4.21" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027" + integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^2: + version "2.1.9" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.1.9.tgz#7d01ffd07a553a51c87170b5e80fea3da7fb41e7" + integrity sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q== + dependencies: + "@vitest/expect" "2.1.9" + "@vitest/mocker" "2.1.9" + "@vitest/pretty-format" "^2.1.9" + "@vitest/runner" "2.1.9" + "@vitest/snapshot" "2.1.9" + "@vitest/spy" "2.1.9" + "@vitest/utils" "2.1.9" + chai "^5.1.2" + debug "^4.3.7" + expect-type "^1.1.0" + magic-string "^0.30.12" + pathe "^1.1.2" + std-env "^3.8.0" + tinybench "^2.9.0" + tinyexec "^0.3.1" + tinypool "^1.0.1" + tinyrainbow "^1.2.0" + vite "^5.0.0" + vite-node "2.1.9" + why-is-node-running "^2.3.0" + vue-collapsed@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/vue-collapsed/-/vue-collapsed-1.3.5.tgz#1ba8e03384d2b0e894e157331cf77ec7ddb8d1dd" integrity sha512-U6wCa4mFpaX2Fr9BWtGNPte3SAgtpk1NjeS/NRLHDHu2fDs3/MQ3W13pvWXy5BGbtz14HxzSq6efC9WrHblozQ== +vue-component-type-helpers@^3.0.0: + version "3.2.7" + resolved "https://registry.yarnpkg.com/vue-component-type-helpers/-/vue-component-type-helpers-3.2.7.tgz#a85fd5ee4f5105883c267859c2d54a2d53e5da20" + integrity sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ== + vue-demi@*, vue-demi@^0.14.10: version "0.14.10" resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04" @@ -6069,6 +6801,11 @@ web-worker@^1.5.0: resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.5.0.tgz#71b2b0fbcc4293e8f0aa4f6b8a3ffebff733dcc5" integrity sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw== +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + webpack-cli@6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-6.0.1.tgz#a1ce25da5ba077151afd73adfa12e208e5089207" @@ -6137,6 +6874,11 @@ webpack@5.99.9: watchpack "^2.4.1" webpack-sources "^3.2.3" +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -6151,6 +6893,14 @@ which@^6.0.0, which@^6.0.1: dependencies: isexe "^4.0.0" +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + wildcard@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" @@ -6161,6 +6911,15 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" @@ -6170,6 +6929,15 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + wrap-ansi@^9.0.0: version "9.0.2" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98"