From 02c59f89232f44c4ef0dd3a4dbbef07f37469e80 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sat, 2 May 2026 14:54:05 +0300 Subject: [PATCH 01/22] feat: add bootstrap loader to popup for improved initial load experience --- src/popup/App.vue | 13 ++++++-- src/popup/components/PopupLoader.vue | 44 ++++++++++++++++++++++++++++ static/popup.html | 30 ++++++++++++++++++- 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 src/popup/components/PopupLoader.vue diff --git a/src/popup/App.vue b/src/popup/App.vue index f01323c..b26d2d4 100644 --- a/src/popup/App.vue +++ b/src/popup/App.vue @@ -1,9 +1,18 @@ 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/static/popup.html b/static/popup.html index a3459ad..d2691b9 100644 --- a/static/popup.html +++ b/static/popup.html @@ -18,11 +18,39 @@ body { width: 785px; height: 600px; + background: #ffffff; + } + /* Bootstrap loader visible until popup.js parses and Vue's mount() + replaces #subturtle-popup's children. Keeps the popup from showing + an empty white frame during the auth chain in router.beforeEach. */ + .popup-bootstrap-loader { + width: 100%; + height: 600px; + display: flex; + align-items: center; + justify-content: center; + } + .popup-bootstrap-loader__spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(168, 85, 247, 0.2); + border-top-color: #a855f7; + border-radius: 50%; + animation: popup-bootstrap-spin 1s linear infinite; + } + @keyframes popup-bootstrap-spin { + to { + transform: rotate(360deg); + } } -
+
+ +
From 5122962f78d28e3f057533098d6bda2879a4fc3d Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sun, 3 May 2026 13:10:33 +0300 Subject: [PATCH 02/22] refactor: decouple ConsoleCrane from UI components by implementing an event-based messaging bridge #86et9bk39 --- src/common/services/console-crane-bridge.ts | 47 +++++++++++++++++ src/console-crane.ts | 51 +++++++++++++++++++ src/console-crane/initializer.ts | 22 ++++++++ src/main.ts | 2 - src/nibble.ts | 2 - src/nibble/components/NibbleSurface.vue | 22 ++++++-- src/nibble/components/SelectionPopup.vue | 14 +++-- .../components/specific/TranslatedPhrase.vue | 2 - src/subtitle/components/specific/Word.vue | 21 +++++--- src/subtitle/web_netflix/Index.vue | 2 - src/subtitle/web_youtube/Index.vue | 4 -- static/manifest.json | 8 +++ webpack.config.js | 1 + 13 files changed, 165 insertions(+), 33 deletions(-) create mode 100644 src/common/services/console-crane-bridge.ts create mode 100644 src/console-crane.ts create mode 100644 src/console-crane/initializer.ts 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/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/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/HomeView.vue b/src/popup/views/HomeView.vue index 9652e9b..30a29db 100644 --- a/src/popup/views/HomeView.vue +++ b/src/popup/views/HomeView.vue @@ -3,35 +3,11 @@
- -
-
-
-
-
-

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

-

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

-
-
- -
-
-
-
-
+ + +
+ + + Want to + +  Log in? + +
@@ -299,6 +288,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); From a0968ec2ba5c551d94151075ce08217675cedbd9 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sun, 3 May 2026 16:22:51 +0300 Subject: [PATCH 05/22] feat: refresh popup help view to cover web text and quick translate #86exfjner Co-Authored-By: Claude Opus 4.7 (1M context) --- src/popup/views/HelpView.vue | 596 +++++++++++++++++++++-------------- 1 file changed, 361 insertions(+), 235 deletions(-) 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. +
+
+
From 9c89f83557f899bc1465de7625d0149489c73dda Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sun, 3 May 2026 16:24:20 +0300 Subject: [PATCH 06/22] feat: add loading state and section divider to popup translate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate button shows a spinner + "Translating…" while the fetch is in flight; disabled while loading. WordDetailModule emits a `loading: boolean` event mirroring its internal pending ref so any caller can reflect the state, and TranslateCard skips no-op resubmits (same word) that wouldn't trigger the prop watcher and would otherwise leave the spinner stuck. - HomeView gets a subtle horizontal gradient divider between the translate card and the settings cards below so the result detail doesn't visually bleed into the rest of the popup. - CLAUDE.md: document the new WordDetailModule prop-driven mounting mode, the cross-bundle reuse carve-out (WordDetailModule etc. are reusable from non-content-script bundles like the popup), the new route-params.ts helper file location, and the popup translate verification flow. - Ignore /.claude config dir in .gitignore. #86exfjner Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + CLAUDE.md | 31 ++++++++++++++-- .../modules/word-detail/index.vue | 7 ++++ src/popup/components/TranslateCard.vue | 37 ++++++++++++++++--- src/popup/views/HomeView.vue | 7 ++++ 5 files changed, 75 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 9cb5671..9e02517 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist static/key-file.json *.zip .npmrc +/.claude \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index a623abe..0e9d39f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ Load `dist/` as an unpacked extension at `chrome://extensions`. There is no sepa | `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.** | | `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 | Settings, language, dashboard link, per-site Nibble toggle. | +| `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). 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). @@ -77,7 +77,9 @@ The console-crane content script listens (`onOpen` in [src/console-crane.ts](src **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` 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). +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 @@ -88,6 +90,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: @@ -207,7 +228,8 @@ When changes touch the bundle layout, content scripts, or shared CSS: - On YouTube `/watch`: subtitle popup works; Nibble selection popup also works (all three content scripts run there). Exactly one `#subturtle-console-crane-root` in the DOM. - On Wikipedia: only `nibble.js` and `console-crane.js` run; selection → icon → translation card → save flow opens ConsoleCrane. - In the popup: per-site toggle reads/writes `nibbleDisabledDomains` and survives a popup re-open. Toggling Nibble OFF for a host **while ConsoleCrane is open** must NOT close the modal or lock page scroll — the modal lifecycle is decoupled from the Nibble per-host gate via the bridge. -- In ConsoleCrane on a non-Latin page (e.g. Persian / Chinese article): no `InvalidCharacterError` from `btoa`. +- In the popup translate input: input is auto-focused on open; submitting renders the detailed result inline; logged-out users see "Login to save this phrase"; logged-in users get the bundle picker. Re-translating a different word resets the result. The button shows a spinner while pending. +- In ConsoleCrane on a non-Latin page (e.g. Persian / Chinese article): no `InvalidCharacterError` from `btoa`. Same check applies to the popup translate input — paste a Persian / CJK phrase and confirm no encoding error. - Visual scale is consistent on a default-html-font-size site (YouTube) and a large-html-font-size site (typical WordPress blog). ## Useful pointers @@ -219,6 +241,9 @@ When changes touch the bundle layout, content scripts, or shared CSS: - 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) diff --git a/src/console-crane/modules/word-detail/index.vue b/src/console-crane/modules/word-detail/index.vue index 2d5e821..818c97b 100644 --- a/src/console-crane/modules/word-detail/index.vue +++ b/src/console-crane/modules/word-detail/index.vue @@ -199,6 +199,10 @@ const props = defineProps<{ context?: string; }>(); +const emit = defineEmits<{ + loading: [boolean]; +}>(); + const route = useRoute(); onMounted(() => { @@ -232,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/popup/components/TranslateCard.vue b/src/popup/components/TranslateCard.vue index 7276dc6..f95611d 100644 --- a/src/popup/components/TranslateCard.vue +++ b/src/popup/components/TranslateCard.vue @@ -23,16 +23,37 @@ />
- +
@@ -46,6 +67,7 @@ const placeholder = "Type or paste any text to translate…"; const inputEl = ref(null); const inputText = ref(""); const submittedWord = ref(""); +const loading = ref(false); onMounted(async () => { await nextTick(); @@ -54,7 +76,12 @@ onMounted(async () => { function submit() { const text = inputText.value.trim(); - if (!text) return; + // Skip if empty or unchanged — re-submitting the same word wouldn't trigger + // WordDetailModule's prop watcher, so the loading flag would never clear. + if (!text || text === submittedWord.value) return; + // Set immediately for snappy feedback; WordDetailModule's emit will + // turn it off when the fetch settles (or hand it back to true on retry). + loading.value = true; submittedWord.value = text; } diff --git a/src/popup/views/HomeView.vue b/src/popup/views/HomeView.vue index 30a29db..c29cc66 100644 --- a/src/popup/views/HomeView.vue +++ b/src/popup/views/HomeView.vue @@ -8,6 +8,13 @@ + +
+
Date: Sun, 3 May 2026 17:16:24 +0300 Subject: [PATCH 07/22] feat: add prerelease support for dev branch with environment-specific changelogs and configs --- .github/workflows/release.yml | 6 ++++ .releaserc.json | 38 ---------------------- CHANGELOG-DEV.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 42 +++++++++++++++--------- package.json | 1 + release.config.cjs | 54 +++++++++++++++++++++++++++++++ scripts/sync-manifest-version.mjs | 23 ++++++++++--- yarn.lock | 10 ++++++ 9 files changed, 118 insertions(+), 58 deletions(-) delete mode 100644 .releaserc.json create mode 100644 CHANGELOG-DEV.md create mode 100644 CHANGELOG.md create mode 100644 release.config.cjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 008b90d..376324c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,16 +4,22 @@ on: push: branches: - main + - dev permissions: contents: write issues: write pull-requests: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: release: name: Release runs-on: ubuntu-latest + environment: ${{ github.ref_name == 'main' && 'prod' || 'dev' }} steps: - name: Checkout uses: actions/checkout@v4 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..94905e0 --- /dev/null +++ b/CHANGELOG-DEV.md @@ -0,0 +1 @@ +# 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 a623abe..e029de2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,9 @@ Add the route to [src/console-crane/router.ts](src/console-crane/router.ts), add ## 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 @@ -150,7 +152,7 @@ Releases are automated by [.github/workflows/release.yml](.github/workflows/rele 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 @@ -164,25 +166,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 @@ -223,5 +232,6 @@ When changes touch the bundle layout, content scripts, or shared CSS: - 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) +- 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) diff --git a/package.json b/package.json index 2a97089..8261719 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "devDependencies": { "@egoist/tailwindcss-icons": "1.7.1", "@iconify/json": "2.2.165", + "@semantic-release/changelog": "^6.0.3", "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", "@types/chrome": "0.0.193", 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/yarn.lock b/yarn.lock index f8b8c78..670a4da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -608,6 +608,16 @@ 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" From f67bd59e529b93738d5834c988be797275b8d8cb Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 3 May 2026 14:17:27 +0000 Subject: [PATCH 08/22] chore(release): 1.11.0-dev.1 [skip ci] # [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)) --- CHANGELOG-DEV.md | 8 ++++++++ package.json | 2 +- static/manifest.json | 5 +++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG-DEV.md b/CHANGELOG-DEV.md index 94905e0..9385f55 100644 --- a/CHANGELOG-DEV.md +++ b/CHANGELOG-DEV.md @@ -1 +1,9 @@ +# [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/package.json b/package.json index 8261719..49b43cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subturtle-extension", - "version": "1.10.1", + "version": "1.11.0-dev.1", "private": true, "scripts": { "dev": "webpack --watch", diff --git a/static/manifest.json b/static/manifest.json index 63f9aba..6a048ab 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,7 +1,7 @@ { "name": "Subturtle", "description": "Turn video subtitles into English lessons. Learn new vocabulary in context as you watch on YouTube and Netflix.", - "version": "1.10.1", + "version": "1.11.0.1", "manifest_version": 3, "icons": { "128": "/assets/logo-128.png", @@ -79,5 +79,6 @@ "" ] } - ] + ], + "version_name": "1.11.0-dev.1" } From 534493c726415a14eb6de97d0afc4c84ec3dd1a5 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 3 May 2026 14:22:16 +0000 Subject: [PATCH 09/22] chore(release): 1.11.0-dev.2 [skip ci] # [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) --- CHANGELOG-DEV.md | 9 +++++++++ package.json | 2 +- static/manifest.json | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG-DEV.md b/CHANGELOG-DEV.md index 9385f55..fa4fb7d 100644 --- a/CHANGELOG-DEV.md +++ b/CHANGELOG-DEV.md @@ -1,3 +1,12 @@ +# [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) diff --git a/package.json b/package.json index 49b43cd..44233f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subturtle-extension", - "version": "1.11.0-dev.1", + "version": "1.11.0-dev.2", "private": true, "scripts": { "dev": "webpack --watch", diff --git a/static/manifest.json b/static/manifest.json index 6a048ab..23a725b 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,7 +1,7 @@ { "name": "Subturtle", "description": "Turn video subtitles into English lessons. Learn new vocabulary in context as you watch on YouTube and Netflix.", - "version": "1.11.0.1", + "version": "1.11.0.2", "manifest_version": 3, "icons": { "128": "/assets/logo-128.png", @@ -80,5 +80,5 @@ ] } ], - "version_name": "1.11.0-dev.1" + "version_name": "1.11.0-dev.2" } From 1f58e69bbfccc292f92008b6e40524ce118bb870 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sun, 3 May 2026 17:40:04 +0300 Subject: [PATCH 10/22] test: add Vitest scaffold and Tier-1 unit tests #86exfn1e3 Bring the repo from zero automated tests to a baseline of regression cover on the highest-signal pure-logic surfaces. All 48 tests run in under 500ms. - vitest.config.ts: happy-dom env; bypasses the webpack-targeted postcss config (which Vite's loader rejects) since unit tests don't import CSS. - tests/setup.ts: minimal hand-rolled chrome.* shim, mixpanel module mock, console.log silencer. - tests/route-params.test.ts: parametrized round-trip across ASCII, accented Latin, Persian, CJK, emoji, mixed - the regression net for the documented btoa/Latin1 InvalidCharacterError class. - tests/console-crane-bridge.test.ts: payload integrity, multi-listener fanout, unsubscribe semantics, channel independence. - tests/translate.service.test.ts: cache hit/miss across (text, context, type, language) and 24h TTL eviction via vi.useFakeTimers. - tests/settings-host.test.ts: setNibbleDisabledForHost host normalization (case fold, www. strip, dedup, removal, subdomain isolation, sendMessage fan-out). - tests/language-detection.test.ts: RTL detection, title lookup, supported-codes registry, setLanguage storage write. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 4 + tests/console-crane-bridge.test.ts | 112 ++++++ tests/language-detection.test.ts | 83 +++++ tests/route-params.test.ts | 36 ++ tests/settings-host.test.ts | 76 ++++ tests/setup.ts | 59 ++++ tests/translate.service.test.ts | 99 ++++++ vitest.config.ts | 18 + yarn.lock | 544 ++++++++++++++++++++++++++++- 9 files changed, 1024 insertions(+), 7 deletions(-) create mode 100644 tests/console-crane-bridge.test.ts create mode 100644 tests/language-detection.test.ts create mode 100644 tests/route-params.test.ts create mode 100644 tests/settings-host.test.ts create mode 100644 tests/setup.ts create mode 100644 tests/translate.service.test.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 44233f6..5496f4d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "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", "release": "semantic-release", "release:dry": "semantic-release --dry-run --no-ci" }, @@ -20,6 +22,7 @@ "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", @@ -32,6 +35,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/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/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/route-params.test.ts b/tests/route-params.test.ts new file mode 100644 index 0000000..2cca5bb --- /dev/null +++ b/tests/route-params.test.ts @@ -0,0 +1,36 @@ +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(); + }); +}); 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.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..2f52eef --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // 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"], + globals: false, + }, +}); diff --git a/yarn.lock b/yarn.lock index 670a4da..e9b1b91 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" @@ -603,6 +718,131 @@ 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" @@ -854,7 +1094,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== @@ -903,6 +1143,65 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== +"@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" @@ -1437,6 +1736,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" @@ -1562,6 +1866,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" @@ -1619,6 +1928,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" @@ -1646,6 +1966,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" @@ -2078,13 +2403,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" @@ -2292,7 +2622,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== @@ -2314,6 +2644,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" @@ -2364,6 +2723,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" @@ -2427,6 +2793,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" @@ -2589,7 +2960,7 @@ fs-minipass@^3.0.0, fs-minipass@^3.0.3: dependencies: minipass "^7.0.3" -fsevents@~2.3.2: +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== @@ -2744,6 +3115,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" @@ -3456,6 +3836,11 @@ lodash@^4.17.4: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== +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: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" @@ -3466,7 +3851,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== @@ -4199,7 +4584,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== @@ -4209,6 +4594,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" @@ -4884,7 +5274,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== @@ -5156,6 +5546,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" @@ -5298,6 +5722,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" @@ -5449,6 +5878,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" @@ -5766,6 +6205,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" @@ -5779,6 +6228,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" @@ -5973,6 +6437,54 @@ 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" @@ -6079,6 +6591,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" @@ -6147,6 +6664,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" @@ -6161,6 +6683,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" From 3c67b532cb290961928058a97a325edb6cb9c021 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sun, 3 May 2026 17:54:23 +0300 Subject: [PATCH 11/22] test: add Tier-2 Vue component and store tests #86exfn1e3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer Vue component coverage on top of the Tier-1 unit baseline. 18 new tests across 3 files; the full suite is now 66 tests in ~630ms. - vitest.config.ts: register @vitejs/plugin-vue so .vue SFCs compile through the same Vite pipeline. - @vue/test-utils, @pinia/testing, @vitejs/plugin-vue added as devDeps. - tests/console-crane-store.test.ts: toggleConsoleCrane activation flow, history push/dedup, force-active, Unicode-safe params, goBack pop+route, canGoBack, isOnMainPage, resetHistory. Router mocked via vi.hoisted so the test doesn't drag in the real page component graph. - tests/selection-popup.test.ts: regression for the "popup deselects page text" bug — root mousedown must call preventDefault (.prevent) and must not bubble to document (.stop). - tests/nibble-surface.test.ts: regression for the "modal closes Nibble" bug class — emitState({isActive:true}) must hide the popup, emitState({isActive:false}) must show it again, and the bridge unsubscribe must be clean across remounts. useTextSelection is mocked with real Vue refs so isVisible can be driven from tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 + tests/console-crane-store.test.ts | 140 ++++++++++++++++++++ tests/nibble-surface.test.ts | 162 +++++++++++++++++++++++ tests/selection-popup.test.ts | 78 +++++++++++ vitest.config.ts | 2 + yarn.lock | 212 +++++++++++++++++++++++++++++- 6 files changed, 592 insertions(+), 5 deletions(-) create mode 100644 tests/console-crane-store.test.ts create mode 100644 tests/nibble-surface.test.ts create mode 100644 tests/selection-popup.test.ts diff --git a/package.json b/package.json index 5496f4d..7668477 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,14 @@ "devDependencies": { "@egoist/tailwindcss-icons": "1.7.1", "@iconify/json": "2.2.165", + "@pinia/testing": "^1", "@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", 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/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/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/vitest.config.ts b/vitest.config.ts index 2f52eef..57c7793 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,8 @@ 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. diff --git a/yarn.lock b/yarn.lock index e9b1b91..d411a15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -359,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" @@ -692,6 +704,21 @@ 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== + "@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" @@ -1143,6 +1170,11 @@ 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" @@ -1394,6 +1426,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" @@ -1577,6 +1617,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" @@ -1688,7 +1733,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== @@ -1775,6 +1820,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" @@ -1831,6 +1881,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" @@ -2112,6 +2169,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" @@ -2155,7 +2217,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== @@ -2519,6 +2581,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" @@ -2539,6 +2616,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" @@ -2920,6 +3002,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" @@ -3063,6 +3153,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" @@ -3482,6 +3584,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" @@ -3501,6 +3612,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" @@ -3841,7 +3968,7 @@ loupe@^3.1.0, loupe@^3.1.2: 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.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== @@ -3970,6 +4097,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" @@ -4021,7 +4155,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== @@ -4123,6 +4257,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" @@ -4448,6 +4589,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" @@ -4571,6 +4717,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" @@ -5901,6 +6055,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" @@ -5910,6 +6073,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" @@ -5926,6 +6098,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" @@ -5933,7 +6112,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== @@ -6490,6 +6669,11 @@ vue-collapsed@^1.3.4: 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" @@ -6701,6 +6885,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" @@ -6710,6 +6903,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" From 22c4a37f879b32e780e57626994778c0c64c4a5b Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sun, 3 May 2026 18:02:22 +0300 Subject: [PATCH 12/22] docs: note env-routing in release-pipeline step 3 Step 3 of "How a release runs" now references the job's `environment:` line and which keys it routes to per-branch, with a forward link to the "Required GitHub Actions config" section that already documents the prod/dev environment vs repo-level split in detail. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 710e3fd..b46cf3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,7 +169,7 @@ A top-level `concurrency:` block keys on `github.ref`, so two pushes to the same 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. From ed950e4ea3c40b2b6b1cffdfce2401a57b70f4fe Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sun, 3 May 2026 18:08:29 +0300 Subject: [PATCH 13/22] test: add Tier-3 Playwright E2E with unpacked extension #86exfn1e3 Layer end-to-end coverage on top of the Tier-1 unit and Tier-2 component tests. 6 specs across 2 files run against a real Chromium with `dist/` loaded as an unpacked MV3 extension; the suite is ~5s once the browser is warm. - playwright.config.ts: testDir tests/e2e, single worker (extensions don't parallelize cleanly), webServer boots the fixtures server. spec/test split keeps Vitest and Playwright from fighting over file ownership. - tests/e2e/server.mjs: ~30-line static-file server, no extra dep. - tests/e2e/extension-fixture.ts: chromium.launchPersistentContext with --load-extension=dist plus --disable-extensions-except so the test browser only sees this extension. Exposes serviceWorker and extensionId fixtures. - tests/e2e/fixtures/{index,persian,large-font}.html: minimal HTML fixtures for English, RTL Persian, and large-html-font-size pages. Generic content-script paths (nibble + console-crane match ) so we never need to touch real youtube.com / netflix.com. - tests/e2e/dist-artifacts.spec.ts: fs-only checks (required entry files, no orphan numeric chunks, manifest declares main / nibble / console-crane content scripts). - tests/e2e/nibble-flow.spec.ts: nibble + console-crane roots mount on a generic page, exactly one console-crane root in the DOM, emitOpen with Persian + emoji + CJK params throws no InvalidCharacterError, double-clicking a word shows the Subturtle icon. - vitest.config.ts: exclude tests/e2e/** and **/*.spec.ts so the Vitest run doesn't try to collect the Playwright specs. - package.json: add `test:e2e` script. - .gitignore: ignore /playwright-report and /test-results. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 +- package.json | 2 + playwright.config.ts | 30 ++++++++++ tests/e2e/dist-artifacts.spec.ts | 46 ++++++++++++++++ tests/e2e/extension-fixture.ts | 49 +++++++++++++++++ tests/e2e/fixtures/index.html | 24 ++++++++ tests/e2e/fixtures/large-font.html | 24 ++++++++ tests/e2e/fixtures/persian.html | 23 ++++++++ tests/e2e/nibble-flow.spec.ts | 88 ++++++++++++++++++++++++++++++ tests/e2e/server.mjs | 37 +++++++++++++ vitest.config.ts | 3 + yarn.lock | 26 +++++++++ 12 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/dist-artifacts.spec.ts create mode 100644 tests/e2e/extension-fixture.ts create mode 100644 tests/e2e/fixtures/index.html create mode 100644 tests/e2e/fixtures/large-font.html create mode 100644 tests/e2e/fixtures/persian.html create mode 100644 tests/e2e/nibble-flow.spec.ts create mode 100644 tests/e2e/server.mjs diff --git a/.gitignore b/.gitignore index 9e02517..497509d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ dist static/key-file.json *.zip .npmrc -/.claude \ No newline at end of file +/.claude +/playwright-report +/test-results \ No newline at end of file diff --git a/package.json b/package.json index 7668477..c089c9e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "zip": "cd dist && zip -r subturtle.zip . && mv subturtle.zip ../subturtle.zip", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "playwright test", "release": "semantic-release", "release:dry": "semantic-release --dry-run --no-ci" }, @@ -15,6 +16,7 @@ "@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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..dbe1b02 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,30 @@ +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, + reporter: process.env.CI ? "list" : [["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/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..f8ed55b --- /dev/null +++ b/tests/e2e/extension-fixture.ts @@ -0,0 +1,49 @@ +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"); + const context = await chromium.launchPersistentContext("", { + channel: "chromium", + args: [ + "--headless=new", + `--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/vitest.config.ts b/vitest.config.ts index 57c7793..ffc8dda 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ 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/yarn.lock b/yarn.lock index d411a15..bfe05ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -719,6 +719,13 @@ 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" @@ -3050,6 +3057,11 @@ fs-minipass@^3.0.0, fs-minipass@^3.0.3: dependencies: minipass "^7.0.3" +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" @@ -4868,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" From be85f93e4a094f74a1515e8dbfe33d6540a13623 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sun, 3 May 2026 23:06:35 +0300 Subject: [PATCH 14/22] ci: gate releases on a verify job that runs unit and e2e tests #86exfn1e3 Add a verify job that runs on every push and pull_request to main/dev, and make the release job depend on it. Failing tests now block the release commit, tag, and zip upload. - New verify job: checkout + dashboard-app sibling clone + Node 22 + yarn install + cached Playwright Chromium + `yarn test` (Vitest) + stub .env.production from .env.example + `yarn build` + `yarn test:e2e` (Playwright). - ~/.cache/ms-playwright cached on yarn.lock hash so cold runs pay the ~150MB Chromium download once and warm runs skip it. - The verify build copies .env.example to .env.production verbatim; dotenv-webpack `safe: true` only requires the keys to exist, and empty values are fine for a dist that's only loaded into Playwright. - The 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. - Top-level `on:` adds `pull_request` to main/dev so PRs get the same gate before merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 70 +++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 376324c..ecb8af5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,10 @@ on: branches: - main - dev + pull_request: + branches: + - main + - dev permissions: contents: write @@ -16,8 +20,74 @@ concurrency: 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: 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. The values can be + # empty for a verify build — none of the network calls fire under + # tests, and the resulting dist/ is only loaded into Playwright. + - name: Stub .env.production for verify build + run: cp .env.example .env.production + + - name: Build extension + run: yarn build + + - name: E2E tests (Playwright) + run: yarn test:e2e + + - name: Upload Playwright report on failure + if: failure() + 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: From 3b5471d0139eb0e64584d36ee74a257303009f15 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sun, 3 May 2026 23:36:31 +0300 Subject: [PATCH 15/22] test: round-out Tier-3 with route-params fix, visual + lifecycle E2E, typecheck gate #86exfn1e3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups bundled to keep the PR focused: a small route-params production fix that the Tier-1 tests had surfaced, two new E2E specs against the loaded extension, and a typecheck round that fixes 8 of 9 pre-existing TS errors and wires the check into the verify gate. Test totals after this lands: 68 unit + 9 E2E (was 66 + 6). route-params undefined round-trip fix: - src/console-crane/route-params.ts: encode skips when JSON.stringify returns undefined; decode returns undefined for empty input. Production path: toggleConsoleCrane(page) without explicit params no longer throws "Unexpected end of JSON input" on the next decode. - tests/route-params.test.ts: restores the two undefined-round-trip cases removed during Tier 1. E2E specs: - tests/e2e/visual-scale.spec.ts: opens ConsoleCrane on a 16px-html fixture and a 24px-html fixture, reads computed font-size of a text-sm element inside the modal, asserts the rendered px is identical. Regression net for the postcss rem→px rewrite. - tests/e2e/console-crane-lifecycle.spec.ts: opens the modal, writes nibbleDisabledDomains:['localhost'] via the extension service worker, asserts the modal stays visible and body scroll stays locked. Paired test confirms scroll is restored when the modal is later closed. Regression net for the "modal lifecycle is decoupled from the Nibble per-host gate" contract documented in CLAUDE.md. Typecheck round (8 of 9 errors fixed; 1 third-party suppressed): - src/vue-shim.d.ts: replace the Vue-2-style default export with the canonical Vue 3 DefineComponent ambient declaration. Clears the 7 errors in src/console-crane/router.ts and src/popup/router.ts where imported .vue files were typed as the bare vue module namespace. - src/background.ts: cast chrome.tabs.sendMessage to Promise. @types/chrome@0.0.193 still types it as void; in MV3 the no-callback overload returns a Promise. Clears 1 error. - scripts/typecheck.mjs: wrap tsc --noEmit and filter out the one remaining third-party error in node_modules/pilotui/src/vue.ts. pilotui's package.json points exports.types at raw TS source, so tsc follows into a file with a mismatched plugin signature against vue3-perfect-scrollbar — not our code to fix. Real errors in our own code still fail the script. - package.json: "typecheck": "node scripts/typecheck.mjs". - .github/workflows/release.yml: new "Type check" step in the verify job, runs before unit tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 3 + package.json | 1 + scripts/typecheck.mjs | 26 ++++ src/background.ts | 10 +- src/console-crane/route-params.ts | 11 +- src/vue-shim.d.ts | 11 +- tests/e2e/console-crane-lifecycle.spec.ts | 149 ++++++++++++++++++++++ tests/e2e/visual-scale.spec.ts | 70 ++++++++++ tests/route-params.test.ts | 15 +++ 9 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 scripts/typecheck.mjs create mode 100644 tests/e2e/console-crane-lifecycle.spec.ts create mode 100644 tests/e2e/visual-scale.spec.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ecb8af5..74cae73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,6 +60,9 @@ jobs: 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 diff --git a/package.json b/package.json index c089c9e..675305f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "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" }, diff --git a/scripts/typecheck.mjs b/scripts/typecheck.mjs new file mode 100644 index 0000000..6dc5cc3 --- /dev/null +++ b/scripts/typecheck.mjs @@ -0,0 +1,26 @@ +// Wrapper around `tsc --noEmit` that filters out one known third-party +// source-TS error. pilotui's package.json points `exports.types` at raw +// TS source files, so tsc follows into `node_modules/pilotui/src/vue.ts` +// which has a mismatched plugin signature against `vue3-perfect-scrollbar`. +// Not our code to fix; we suppress just this file path so a real +// regression in our own code still fails the check. +import { spawnSync } from "node:child_process"; + +const r = spawnSync("npx", ["tsc", "--noEmit"], { encoding: "utf8" }); +const output = (r.stdout || "") + (r.stderr || ""); + +const errorLines = output + .split("\n") + .filter((l) => /error TS\d+/.test(l)) + .filter((l) => !l.startsWith("node_modules/pilotui/")); + +if (errorLines.length > 0) { + // Print the raw tsc output so the user sees the full error context. + console.error(output); + process.exit(1); +} + +// Soft pilotui errors still get printed (so a future maintainer notices) +// but don't fail the check. +if (output.trim()) console.log(output.trim()); +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/console-crane/route-params.ts b/src/console-crane/route-params.ts index d9c436e..8986b24 100644 --- a/src/console-crane/route-params.ts +++ b/src/console-crane/route-params.ts @@ -6,14 +6,21 @@ // `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 bytes = new TextEncoder().encode(JSON.stringify(params)); + 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 { +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); diff --git a/src/vue-shim.d.ts b/src/vue-shim.d.ts index ad17f79..d363ef8 100644 --- a/src/vue-shim.d.ts +++ b/src/vue-shim.d.ts @@ -1,4 +1,9 @@ +// Vue 3 SFC ambient declaration. The previous shim used Vue 2's default +// export shape, which made TS infer the bare `vue` module namespace at every +// .vue import site — see the 7 errors in src/console-crane/router.ts and +// src/popup/router.ts. declare module "*.vue" { - import Vue from "vue"; - export default Vue; -} \ No newline at end of file + import type { DefineComponent } from "vue"; + const component: DefineComponent<{}, {}, any>; + export default component; +} 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/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/route-params.test.ts b/tests/route-params.test.ts index 2cca5bb..38e58d3 100644 --- a/tests/route-params.test.ts +++ b/tests/route-params.test.ts @@ -33,4 +33,19 @@ describe("route-params encode/decode round-trip", () => { 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(); + }); }); From a2d4e7d27346b95f3ff4c58cede473e08550392e Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Mon, 4 May 2026 00:31:27 +0300 Subject: [PATCH 16/22] test: cover popup translate input and Persian translate-save E2E #86exfn1e3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two final wish-list items: a Vitest component test for the popup's TranslateCard input, and a Playwright E2E that stubs the modular-rest endpoint to cement the full Persian translate-and-save flow. Test totals after this lands: 79 unit + 11 E2E (was 68 + 9). tests/translate-card.test.ts (11 tests): - Auto-focus on mount; disabled when input is empty; disabled on whitespace-only input; enabled with text; trims surrounding whitespace; renders WordDetailModule with the typed word on submit; resets when a different word is submitted; shows the spinner + "Translating…" label after submit; clears the spinner when the child emits loading=false; disables while pending; ignores re-submitting the same word (no double-fetch on enter mash). - WordDetailModule is stubbed at module-resolve time so the modular-rest + auth chain never evaluates. tests/e2e/translate-flow.spec.ts (2 tests): - Intercepts POST /function/run via Playwright page.route to return a Persian-payload stub for translateWithContext (both simple and detailed types); also stubs /user/** to keep anonymous-login error noise out of the console capture. - Spec 1: select 'سلام' on persian.html → click Subturtle icon → translated card renders the stubbed Persian content, no InvalidCharacterError. - Spec 2: same flow + click Save → ConsoleCrane modal opens with WordDetail rendering 'سلام'. Proves Persian survives encodeRouteParams end-to-end through the bridge → store → vue-router pipeline that previously crashed under btoa. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/e2e/translate-flow.spec.ts | 155 +++++++++++++++++++++++++++ tests/translate-card.test.ts | 174 +++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 tests/e2e/translate-flow.spec.ts create mode 100644 tests/translate-card.test.ts 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/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(); + }); +}); From 13f31b015342d859b774f151127f195649024ca8 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Mon, 4 May 2026 00:47:56 +0300 Subject: [PATCH 17/22] ci: filter dashboard-app paths from typecheck and gate playwright report upload #86exfn1e3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verify job's typecheck step was failing because src/stores/profile.ts imports types from the sibling dashboard-app repo. CI clones dashboard-app without installing its dependencies, so when tsc walks the import chain (database.type.ts → server/src/modules/phrase_bundle/db.ts) it can't resolve mongoose / stripe / @modular-rest/server. Locally the typecheck passes because maintainers usually have dashboard-app's own node_modules installed. scripts/typecheck.mjs: - Extend the suppression list (previously just node_modules/pilotui/) to also drop ../dashboard-app/* and dashboard-app/* errors. Same rationale as the pilotui filter — upstream code we don't own. Real errors in our own src/* still surface. .github/workflows/release.yml: - Guard the "Upload Playwright report on failure" step with hashFiles so it skips silently when an earlier step (typecheck or unit tests) fails before Playwright runs. The previous run produced a "no files found" warning artifact that obscured the actual failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 5 ++++- scripts/typecheck.mjs | 40 +++++++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74cae73..5272dde 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,7 +80,10 @@ jobs: run: yarn test:e2e - name: Upload Playwright report on failure - if: failure() + # Only run when an earlier step failed AND the report directory + # was actually produced. A typecheck or unit-test failure exits + # before Playwright runs, so there's nothing to upload. + if: failure() && hashFiles('playwright-report/**') != '' uses: actions/upload-artifact@v4 with: name: playwright-report diff --git a/scripts/typecheck.mjs b/scripts/typecheck.mjs index 6dc5cc3..e3e2ccf 100644 --- a/scripts/typecheck.mjs +++ b/scripts/typecheck.mjs @@ -1,26 +1,48 @@ -// Wrapper around `tsc --noEmit` that filters out one known third-party -// source-TS error. pilotui's package.json points `exports.types` at raw -// TS source files, so tsc follows into `node_modules/pilotui/src/vue.ts` -// which has a mismatched plugin signature against `vue3-perfect-scrollbar`. -// Not our code to fix; we suppress just this file path so a real -// regression in our own code still fails the check. +// 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. +// +// Real errors in our own code still fail the check. import { spawnSync } from "node:child_process"; +const SUPPRESSED_PATHS = [ + "node_modules/pilotui/", + "../dashboard-app/", + // tsc may print sibling-repo paths in absolute or relative form depending + // on cwd; cover the bare directory name too so a fresh `git clone ../dashboard-app` + // in CI is captured regardless. + "dashboard-app/", +]; + +function isSuppressed(line) { + return SUPPRESSED_PATHS.some((p) => line.startsWith(p)); +} + const r = spawnSync("npx", ["tsc", "--noEmit"], { encoding: "utf8" }); const output = (r.stdout || "") + (r.stderr || ""); const errorLines = output .split("\n") .filter((l) => /error TS\d+/.test(l)) - .filter((l) => !l.startsWith("node_modules/pilotui/")); + .filter((l) => !isSuppressed(l)); if (errorLines.length > 0) { - // Print the raw tsc output so the user sees the full error context. console.error(output); process.exit(1); } -// Soft pilotui errors still get printed (so a future maintainer notices) +// Soft suppressed errors still get printed (so a future maintainer notices) // but don't fail the check. if (output.trim()) console.log(output.trim()); process.exit(0); From aed4dc32f80604723e3a754aeeea6b03d30c7263 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Mon, 4 May 2026 01:05:44 +0300 Subject: [PATCH 18/22] ci: drop --headless=new arg and emit playwright HTML report in CI #86exfn1e3 Two changes targeting the verify job's e2e failure: 1. tests/e2e/extension-fixture.ts: drop the explicit --headless=new chromium arg. Playwright 1.40+ with Chromium 121+ runs extensions cleanly under managed headless; the explicit flag works on macOS but appears to mis-toggle on Ubuntu CI runners with the bundled Chromium 147 that Playwright pulls. Letting Playwright pick the headless mode itself is the documented happy path. 2. playwright.config.ts: emit the HTML report unconditionally (previously only in local runs). The verify workflow uploads playwright-report/ on failure, but the previous CI run had nothing to upload because the list reporter doesn't write artifacts. With the html reporter always on, the next failure (if any) will have a downloadable report we can actually open. Local sanity: 11/11 e2e still pass (12.5s). Co-Authored-By: Claude Opus 4.7 (1M context) --- playwright.config.ts | 6 +++++- tests/e2e/extension-fixture.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index dbe1b02..6f7fdb8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,7 +12,11 @@ export default defineConfig({ testMatch: ["**/*.spec.ts"], fullyParallel: false, workers: 1, - reporter: process.env.CI ? "list" : [["list"], ["html", { open: "never" }]], + // 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", diff --git a/tests/e2e/extension-fixture.ts b/tests/e2e/extension-fixture.ts index f8ed55b..b234c7b 100644 --- a/tests/e2e/extension-fixture.ts +++ b/tests/e2e/extension-fixture.ts @@ -19,10 +19,13 @@ type ExtensionFixtures = { export const test = base.extend({ context: async ({}, use) => { const pathToExtension = path.resolve(process.cwd(), "dist"); + // Playwright 1.40+ + Chromium 121+ run extensions cleanly under + // managed headless. The previous `--headless=new` arg worked on + // macOS but fails on Ubuntu CI runners with the bundled Chromium + // 147, so we let Playwright pick the headless mode itself. const context = await chromium.launchPersistentContext("", { channel: "chromium", args: [ - "--headless=new", `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`, ], From 120bc1975bbab284b4091805f62ada530e28d71f Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Mon, 4 May 2026 01:17:13 +0300 Subject: [PATCH 19/22] ci: restore --headless=new and add Linux sandbox flags so extensions load #86exfn1e3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous run on Linux CI failed every browser-loading e2e test at toBeAttached for #subturtle-nibble-root / #subturtle-console-crane-root — the extension's content scripts never injected. Root cause: When `headless: true` is in effect (Playwright's default) and no `--headless=new` arg is passed, Playwright selects the lightweight `chrome-headless-shell` binary, which does NOT support extensions. The previous commit dropping `--headless=new` based on macOS behaviour was wrong; on Linux that flag is what forces the *full* Chromium binary in new-headless mode, which DOES run content scripts. Also adding three flags that are standard CI hygiene for Chromium under GitHub Actions runners — harmless on macOS, sometimes required on Linux: - --no-sandbox - --disable-setuid-sandbox - --disable-dev-shm-usage Local sanity: 11/11 e2e still pass (12.4s). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/e2e/extension-fixture.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/e2e/extension-fixture.ts b/tests/e2e/extension-fixture.ts index b234c7b..77f3bd7 100644 --- a/tests/e2e/extension-fixture.ts +++ b/tests/e2e/extension-fixture.ts @@ -19,13 +19,22 @@ type ExtensionFixtures = { export const test = base.extend({ context: async ({}, use) => { const pathToExtension = path.resolve(process.cwd(), "dist"); - // Playwright 1.40+ + Chromium 121+ run extensions cleanly under - // managed headless. The previous `--headless=new` arg worked on - // macOS but fails on Ubuntu CI runners with the bundled Chromium - // 147, so we let Playwright pick the headless mode itself. + + // 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}`, ], From eae75987831a4989d5ce6fd88c51e360e56f3a91 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Mon, 4 May 2026 11:55:40 +0300 Subject: [PATCH 20/22] ci: write non-empty stub env values + fix typecheck filter for prefixed log lines #86exfn1e3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the verify gate after the previous --headless=new restore. scripts/typecheck.mjs: - The CI run still failed typecheck because GitHub Actions prefixes workflow log lines with `Error: `, so the previous filter's `startsWith("../dashboard-app/")` check never matched. Replace it with a regex that captures the file path before `(line,col): error TS:` regardless of any prefix tokens, then checks that path against the suppressed-fragment list. Pilotui + dashboard-app errors are suppressed; real errors in our code still surface. .github/workflows/release.yml: - Verify e2e tests were all timing out at toBeAttached on #subturtle-nibble-root / #subturtle-console-crane-root despite --headless=new. Tracing the failed runs showed content scripts start (Trusted Types polyfill logs fire from both nibble.js and console-crane.js) but never reach the next top-level statement, with zero subsequent network activity. Root cause: the CI build was using an empty .env.production (cp from .env.example), and `mixpanel.init("")` in src/plugins/mixpanel.ts throws synchronously during the content-script import chain — silently halting every Vue mount. - Replace `cp .env.example .env.production` with a heredoc that writes non-empty placeholders. SUBTURTLE_API_URL points at the local fixtures server (http://localhost:4173) so any auth or translate calls hit 404 instead of escaping to the real backend. Other vars use harmless ci_e2e_stub_* tokens. Local sanity: typecheck clean, 11/11 e2e still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 23 +++++++++++++++++++---- scripts/typecheck.mjs | 15 +++++++++------ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5272dde..86581d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,11 +67,26 @@ jobs: run: yarn test # dotenv-webpack is configured with `safe: true`, so the build needs - # every key in .env.example to exist at build time. The values can be - # empty for a verify build — none of the network calls fire under - # tests, and the resulting dist/ is only loaded into Playwright. + # 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: cp .env.example .env.production + 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 diff --git a/scripts/typecheck.mjs b/scripts/typecheck.mjs index e3e2ccf..ad3777e 100644 --- a/scripts/typecheck.mjs +++ b/scripts/typecheck.mjs @@ -16,17 +16,20 @@ // Real errors in our own code still fail the check. import { spawnSync } from "node:child_process"; -const SUPPRESSED_PATHS = [ +const SUPPRESSED_PATH_FRAGMENTS = [ "node_modules/pilotui/", - "../dashboard-app/", - // tsc may print sibling-repo paths in absolute or relative form depending - // on cwd; cover the bare directory name too so a fresh `git clone ../dashboard-app` - // in CI is captured regardless. "dashboard-app/", ]; +// Extracts the file path that prefixes a tsc error line. The line is +// always `(,): error TS:` (non-pretty mode), but +// CI loggers may prepend tokens like "Error: " — we match anywhere. +const FILE_AT_ERROR = /([^\s:]+\.(?:ts|tsx|d\.ts|vue))\(\d+,\d+\):\s*error\s+TS\d+/; + function isSuppressed(line) { - return SUPPRESSED_PATHS.some((p) => line.startsWith(p)); + 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" }); From e63f078bde78eb0adc057c307c4a1e0a80c6c958 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Mon, 4 May 2026 12:06:37 +0300 Subject: [PATCH 21/22] ci: clean typecheck summary output and upload playwright report on success too #86exfn1e3 Two cosmetic but real follow-ups after the verify gate went green: scripts/typecheck.mjs: - The previous version printed the suppressed pilotui / dashboard-app errors verbatim to stdout on clean runs so a future maintainer could notice them. GitHub Actions' log parser auto-prefixes any line matching `(line,col): error TS:` with a red `Error:` annotation, making a passing job look broken. - Replace the verbose dump with a one-line summary ("typecheck clean. (N upstream errors ... suppressed)"). On real failures we still print the full tsc output for context. The suppression list and rationale stay documented in the file header. .github/workflows/release.yml: - Upload the Playwright HTML report on every run (success or failure), gated only by hashFiles so we still skip silently when typecheck / unit tests fail before Playwright produces any output. Makes green-run debugging possible without re-running the suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 11 ++++++----- scripts/typecheck.mjs | 33 +++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86581d3..871f50c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,11 +94,12 @@ jobs: - name: E2E tests (Playwright) run: yarn test:e2e - - name: Upload Playwright report on failure - # Only run when an earlier step failed AND the report directory - # was actually produced. A typecheck or unit-test failure exits - # before Playwright runs, so there's nothing to upload. - if: failure() && hashFiles('playwright-report/**') != '' + - 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 diff --git a/scripts/typecheck.mjs b/scripts/typecheck.mjs index ad3777e..c1675e4 100644 --- a/scripts/typecheck.mjs +++ b/scripts/typecheck.mjs @@ -13,7 +13,9 @@ // installing its deps; locally maintainers usually have them. Either // way, those errors aren't actionable from here. // -// Real errors in our own code still fail the check. +// 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 = [ @@ -21,9 +23,6 @@ const SUPPRESSED_PATH_FRAGMENTS = [ "dashboard-app/", ]; -// Extracts the file path that prefixes a tsc error line. The line is -// always `(,): error TS:` (non-pretty mode), but -// CI loggers may prepend tokens like "Error: " — we match anywhere. const FILE_AT_ERROR = /([^\s:]+\.(?:ts|tsx|d\.ts|vue))\(\d+,\d+\):\s*error\s+TS\d+/; function isSuppressed(line) { @@ -35,17 +34,27 @@ function isSuppressed(line) { const r = spawnSync("npx", ["tsc", "--noEmit"], { encoding: "utf8" }); const output = (r.stdout || "") + (r.stderr || ""); -const errorLines = output - .split("\n") - .filter((l) => /error TS\d+/.test(l)) - .filter((l) => !isSuppressed(l)); +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 (errorLines.length > 0) { +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); } -// Soft suppressed errors still get printed (so a future maintainer notices) -// but don't fail the check. -if (output.trim()) console.log(output.trim()); +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); From ddeb5e4cce2ff8601f199d60bbe700d63fd1ae04 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Mon, 4 May 2026 12:25:18 +0300 Subject: [PATCH 22/22] docs: document the testing setup in CLAUDE.md #86exfn1e3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four updates, all aligned with the doc's existing voice: - Quick start: add yarn test / test:watch / test:e2e / typecheck to the script list. Flip npm → yarn to match the actual lockfile. - New ## Testing section before ## Verification checklist. Covers: stack table (Vitest unit/component, Playwright e2e, tsc wrapper); Vitest setup (chrome shim, postcss bypass rationale); Playwright setup (extension fixture, fixtures server, fixture pages, no real YouTube/Netflix); the critical Chromium flags with a "changing them breaks CI silently" warning around --headless=new (forces full Chromium, otherwise chrome-headless-shell skips extensions); the CI verify gate step list with a heredoc-stub-or-mixpanel-throws warning around .env.production; typecheck wrapper rationale (the pilotui + dashboard-app suppression list); full test file map; and totals (79 unit + 11 e2e, ~15s warm). - ## Verification checklist: re-framed as "most of this is automated" with each bullet now cross-referencing the spec file that pins it. Residual manual items reduced to YouTube/Netflix subtitle behaviour and the popup full-page lifecycle. - ## Useful pointers: add Vitest config, tests/setup.ts, playwright config, extension fixture, fixtures server, typecheck wrapper, and the Vue 3 SFC ambient declaration. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 138 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 126 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b46cf3a..f28b844 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,9 +5,14 @@ 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. @@ -229,17 +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`, `console-crane.js`, `popup.js`, `popup.html`, `manifest.json`, `assets/` (no orphan numeric chunks). -- On YouTube `/watch`: subtitle popup works; Nibble selection popup also works (all three content scripts run there). Exactly one `#subturtle-console-crane-root` in the DOM. -- On Wikipedia: only `nibble.js` and `console-crane.js` run; selection → icon → translation card → save flow opens ConsoleCrane. -- In the popup: per-site toggle reads/writes `nibbleDisabledDomains` and survives a popup re-open. Toggling Nibble OFF for a host **while ConsoleCrane is open** must NOT close the modal or lock page scroll — the modal lifecycle is decoupled from the Nibble per-host gate via the bridge. -- In the popup translate input: input is auto-focused on open; submitting renders the detailed result inline; logged-out users see "Login to save this phrase"; logged-in users get the bundle picker. Re-translating a different word resets the result. The button shows a spinner while pending. -- In ConsoleCrane on a non-Latin page (e.g. Persian / Chinese article): no `InvalidCharacterError` from `btoa`. Same check applies to the popup translate input — paste a Persian / CJK phrase and confirm no encoding error. -- 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 @@ -256,7 +363,14 @@ When changes touch the bundle layout, content scripts, or shared CSS: - 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) +- 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)