From eb448f63765a71d6d0f4ddb60f0745c50931d4f3 Mon Sep 17 00:00:00 2001 From: Neko Date: Thu, 2 Apr 2026 15:22:37 +0800 Subject: [PATCH] feat(devtool-capture-stage-tamagotchi): tool for interacting the electron apps (#1549) Authored-by-agent: Codex <267193182+codex@users.noreply.github.com> --- package.json | 1 + .../README.md | 126 +++++++++++++++++ .../package.json | 26 ++++ .../src/cli/capture.test.ts | 59 ++++++++ .../src/cli/capture.ts | 103 ++++++++++++++ .../src/index.ts | 11 ++ .../src/runtime/capture.ts | 33 +++++ .../src/runtime/context.ts | 61 ++++++++ .../src/runtime/define-scenario.ts | 5 + .../src/runtime/load-scenario.ts | 58 ++++++++ .../src/runtime/types.ts | 48 +++++++ .../demo-controls-settings-chat-websocket.ts | 31 ++++ .../src/scenarios/demo-dismiss-surfaces.ts | 12 ++ .../src/scenarios/demo-hearing-dialog.ts | 16 +++ .../src/scenarios/settings-connection.ts | 16 +++ .../src/utils/app-path.ts | 24 ++++ .../src/utils/overlays.ts | 94 +++++++++++++ .../src/utils/selectors.ts | 40 ++++++ .../src/utils/settings.ts | 132 ++++++++++++++++++ .../src/utils/windows.ts | 89 ++++++++++++ .../tsconfig.json | 18 +++ .../vitest.config.ts | 7 + pnpm-lock.yaml | 54 +++++-- pnpm-workspace.yaml | 1 + 24 files changed, 1053 insertions(+), 12 deletions(-) create mode 100644 packages/devtool-capture-stage-tamagotchi/README.md create mode 100644 packages/devtool-capture-stage-tamagotchi/package.json create mode 100644 packages/devtool-capture-stage-tamagotchi/src/cli/capture.test.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/cli/capture.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/index.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/runtime/capture.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/runtime/context.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/runtime/define-scenario.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/runtime/load-scenario.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/runtime/types.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-controls-settings-chat-websocket.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-dismiss-surfaces.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-hearing-dialog.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/scenarios/settings-connection.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/utils/app-path.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/utils/overlays.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/utils/selectors.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/utils/settings.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/src/utils/windows.ts create mode 100644 packages/devtool-capture-stage-tamagotchi/tsconfig.json create mode 100644 packages/devtool-capture-stage-tamagotchi/vitest.config.ts diff --git a/package.json b/package.json index 306f6f87f..61f8db771 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "lint": "moeru-lint .", "lint:fix": "moeru-lint --fix .", "lint:swift": "pnpm -rF @proj-airi/stage-pocket run lint:swift", + "capture:tamagotchi": "pnpm -F @proj-airi/devtool-capture-stage-tamagotchi capture --output-dir ./artifacts/manual-run", "to-avif": "tsx docs/scripts/avif.ts", "sponsors:generate": "sponsorkit --output-dir docs/content/public/assets/sponsors", "typecheck": "pnpm -rF=\"./packages/*\" -F=\"./apps/*\" -F=\"./docs\" --parallel typecheck", diff --git a/packages/devtool-capture-stage-tamagotchi/README.md b/packages/devtool-capture-stage-tamagotchi/README.md new file mode 100644 index 000000000..bc1724746 --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/README.md @@ -0,0 +1,126 @@ +# DevTool - Capture Stage Tamagotchi + +Capture screenshots from the built `stage-tamagotchi` Electron app with TypeScript scenarios. + +## Purpose + +This package now provides three things: + +- a runtime surface in `src/index.ts` +- the `capture` CLI in `src/cli/capture.ts` +- the `defineScenario()` authoring helper for scenario modules + +Legacy POC-only files are gone from the package surface. The package is now focused on capture/runtime behavior rather than Playwright test-runner scaffolding or docs generation. + +## Usage + +```bash +pnpm -F @proj-airi/stage-tamagotchi build +pnpm -F @proj-airi/devtool-capture-stage-tamagotchi capture -- src/scenarios/settings-connection.ts --output-dir ./artifacts/manual-run +``` + +## Demo Walkthrough + +This package also ships a demo scenario that captures these states in order: + +- `00-controls-island-expanded` +- `01-settings-window` +- `02-chat-window` +- `03-websocket-settings` + +Run it from the repo root: + +```bash +pnpm -F @proj-airi/stage-tamagotchi build +pnpm -F @proj-airi/devtool-capture-stage-tamagotchi capture -- src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir ./artifacts/demo-run +``` + +Expected files: + +- `packages/devtool-capture-stage-tamagotchi/artifacts/demo-run/00-controls-island-expanded.png` +- `packages/devtool-capture-stage-tamagotchi/artifacts/demo-run/01-settings-window.png` +- `packages/devtool-capture-stage-tamagotchi/artifacts/demo-run/02-chat-window.png` +- `packages/devtool-capture-stage-tamagotchi/artifacts/demo-run/03-websocket-settings.png` + +To verify the controls-island hearing button specifically: + +```bash +pnpm -F @proj-airi/stage-tamagotchi build +pnpm -F @proj-airi/devtool-capture-stage-tamagotchi capture -- src/scenarios/demo-hearing-dialog.ts --output-dir ./artifacts/hearing-demo +``` + +Expected file: + +- `packages/devtool-capture-stage-tamagotchi/artifacts/hearing-demo/hearing-dialog.png` + +## Scenario Authoring + +```ts +import { defineScenario } from '@proj-airi/devtool-capture-stage-tamagotchi' + +export default defineScenario({ + id: 'settings-connection', + async run({ controlsIsland, settingsWindow, stageWindows, capture }) { + const main = await stageWindows.waitFor('main') + await controlsIsland.expand(main.page) + const settings = await controlsIsland.openSettings(main.page) + const page = await settingsWindow.goToConnection(settings.page) + await capture('connection-settings', page) + }, +}) +``` + +For the controls-island hearing trigger, the runtime also provides: + +- `controlsIsland.openHearing(page)` + +## Dialog And Drawer Helpers + +For surfaces built with `DialogRoot` or `DrawerRoot`, the runtime now exposes: + +- `dialogs.dismiss(page)` +- `drawers.swipeDown(page)` +- `drawers.dismiss(page)` + +Example: + +```ts +import { defineScenario } from '@proj-airi/devtool-capture-stage-tamagotchi' + +export default defineScenario({ + id: 'dismiss-helpers', + async run({ dialogs, drawers, stageWindows }) { + const main = await stageWindows.waitFor('main') + + await dialogs.dismiss(main.page) + await drawers.swipeDown(main.page) + await drawers.dismiss(main.page) + }, +}) +``` + +These are best-effort automation helpers. The current behavior is: + +- dialog dismiss: `Escape`, then overlay-corner click fallback +- drawer dismiss: swipe down, then `Escape`, then overlay-corner click fallback + +They are intended for scenarios where you already opened the dialog or drawer and need a reusable close step. + +## Settings Window Helpers + +The `settingsWindow` surface is navigation-only: + +- `settingsWindow.waitFor(timeout?)` +- `settingsWindow.goToConnection(page)` + +It does not open the settings window from the main window for you. The intended flow is: + +1. `stageWindows.waitFor('main')` +2. `controlsIsland.expand(main.page)` +3. `controlsIsland.openSettings(main.page)` +4. `settingsWindow.goToConnection(settings.page)` + +## Notes + +- Importing `@proj-airi/devtool-capture-stage-tamagotchi` now resolves to `src/index.ts` via the package export surface. +- The package is no longer a Playwright test suite package. diff --git a/packages/devtool-capture-stage-tamagotchi/package.json b/packages/devtool-capture-stage-tamagotchi/package.json new file mode 100644 index 000000000..e2aefc9aa --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/package.json @@ -0,0 +1,26 @@ +{ + "name": "@proj-airi/devtool-capture-stage-tamagotchi", + "type": "module", + "version": "0.9.0-beta.3", + "private": true, + "description": "Playwright-driven Electron capture tooling for stage-tamagotchi", + "author": { + "name": "Moeru AI Project AIRI Team", + "email": "airi@moeru.ai", + "url": "https://github.com/moeru-ai" + }, + "license": "MIT", + "exports": "./src/index.ts", + "scripts": { + "capture": "tsx src/cli/capture.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@moeru/std": "catalog:", + "@types/node": "^24.12.0", + "meow": "catalog:", + "playwright": "^1.56.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/devtool-capture-stage-tamagotchi/src/cli/capture.test.ts b/packages/devtool-capture-stage-tamagotchi/src/cli/capture.test.ts new file mode 100644 index 000000000..7c0012766 --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/cli/capture.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' + +import { parseCaptureCliArguments } from './capture' + +describe('parseCaptureCliArguments', () => { + it('accepts a scenario path with --output-dir', () => { + expect(parseCaptureCliArguments([ + 'src/scenarios/settings-connection.ts', + '--output-dir', + './artifacts/manual-run', + ])).toEqual({ + scenarioPath: 'src/scenarios/settings-connection.ts', + outputDir: './artifacts/manual-run', + }) + }) + + it('accepts the -o alias', () => { + expect(parseCaptureCliArguments([ + 'src/scenarios/settings-connection.ts', + '-o', + './artifacts/manual-run', + ])).toEqual({ + scenarioPath: 'src/scenarios/settings-connection.ts', + outputDir: './artifacts/manual-run', + }) + }) + + it('accepts --output-dir=value', () => { + expect(parseCaptureCliArguments([ + 'src/scenarios/settings-connection.ts', + '--output-dir=./artifacts/manual-run', + ])).toEqual({ + scenarioPath: 'src/scenarios/settings-connection.ts', + outputDir: './artifacts/manual-run', + }) + }) + + it('rejects missing scenario path', () => { + expect(() => parseCaptureCliArguments([ + '--output-dir', + './artifacts/manual-run', + ])).toThrow('Usage: capture --output-dir ') + }) + + it('rejects missing output directory', () => { + expect(() => parseCaptureCliArguments([ + 'src/scenarios/settings-connection.ts', + ])).toThrow('Usage: capture --output-dir ') + }) + + it('rejects extra positional arguments', () => { + expect(() => parseCaptureCliArguments([ + 'src/scenarios/settings-connection.ts', + 'src/scenarios/demo-hearing-dialog.ts', + '--output-dir', + './artifacts/manual-run', + ])).toThrow('Usage: capture --output-dir ') + }) +}) diff --git a/packages/devtool-capture-stage-tamagotchi/src/cli/capture.ts b/packages/devtool-capture-stage-tamagotchi/src/cli/capture.ts new file mode 100644 index 000000000..64dca9acf --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/cli/capture.ts @@ -0,0 +1,103 @@ +import path from 'node:path' +import process from 'node:process' + +import { mkdir } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +import meow from 'meow' + +import { errorMessageFrom } from '@moeru/std' +import { _electron as electron } from 'playwright' + +import { createScenarioContext } from '../runtime/context' +import { loadScenarioModule } from '../runtime/load-scenario' +import { resolveElectronAppInfo } from '../utils/app-path' + +interface CaptureCliArguments { + scenarioPath: string + outputDir: string +} + +const captureHelpText = ` + Capture screenshots for a given scenario by running the Electron app and executing the scenario's steps. + + Usage + $ capture --output-dir + + Options + --output-dir, -o Directory to write PNG screenshots into + + Examples + $ capture src/scenarios/settings-connection.ts --output-dir ./artifacts/manual-run + $ capture src/scenarios/settings-connection.ts -o ./artifacts/manual-run +` + +const captureUsageMessage = 'Usage: capture --output-dir ' + +function normalizeCliArgv(argv: string[]): string[] { + return argv[0] === '--' ? argv.slice(1) : argv +} + +function isDirectExecution(): boolean { + if (!process.argv[1]) { + return false + } + + return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +} + +export function parseCaptureCliArguments(argv: string[]): CaptureCliArguments { + const cli = meow(captureHelpText, { + argv: normalizeCliArgv(argv), + importMeta: import.meta, + flags: { + outputDir: { + shortFlag: 'o', + type: 'string', + }, + }, + }) + + if (cli.input.length !== 1 + || typeof cli.flags.outputDir !== 'string' + || cli.flags.outputDir.length === 0) { + throw new Error(captureUsageMessage) + } + + return { + scenarioPath: cli.input[0], + outputDir: cli.flags.outputDir, + } +} + +async function main(): Promise { + const { scenarioPath, outputDir } = parseCaptureCliArguments(process.argv.slice(2)) + const resolvedOutputDir = path.resolve(process.cwd(), outputDir) + + await mkdir(resolvedOutputDir, { recursive: true }) + + const [appInfo, loadedScenario] = await Promise.all([ + resolveElectronAppInfo(), + loadScenarioModule(scenarioPath), + ]) + + const electronApp = await electron.launch({ + args: [appInfo.mainEntrypoint], + cwd: appInfo.repoRoot, + }) + + try { + const context = createScenarioContext(electronApp, resolvedOutputDir) + await loadedScenario.scenario.run(context) + } + finally { + await electronApp.close() + } +} + +if (isDirectExecution()) { + void main().catch((error) => { + console.error(errorMessageFrom(error) ?? 'Unknown CLI error') + process.exitCode = 1 + }) +} diff --git a/packages/devtool-capture-stage-tamagotchi/src/index.ts b/packages/devtool-capture-stage-tamagotchi/src/index.ts new file mode 100644 index 000000000..3e34061dc --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/index.ts @@ -0,0 +1,11 @@ +export { defineScenario } from './runtime/define-scenario' +export type { + CaptureOptions, + ControlsIslandApi, + DialogsApi, + DrawersApi, + ScenarioContext, + SettingsWindowApi, + StageTamagotchiScenario, + StageWindowsApi, +} from './runtime/types' diff --git a/packages/devtool-capture-stage-tamagotchi/src/runtime/capture.ts b/packages/devtool-capture-stage-tamagotchi/src/runtime/capture.ts new file mode 100644 index 000000000..f486a3b5f --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/runtime/capture.ts @@ -0,0 +1,33 @@ +import type { Page } from 'playwright' + +import type { CaptureOptions } from './types' + +import path from 'node:path' + +import { mkdir } from 'node:fs/promises' + +const nonFilenameCharactersPattern = /[^a-z0-9-_]+/g +const edgeDashPattern = /^-+|-+$/g + +function sanitizeCaptureName(name: string): string { + const sanitized = name + .trim() + .toLowerCase() + .replace(nonFilenameCharactersPattern, '-') + .replace(edgeDashPattern, '') + + return sanitized.length > 0 ? sanitized : 'capture' +} + +export async function capturePage(outputDir: string, name: string, page: Page, options?: CaptureOptions): Promise { + const filePath = path.resolve(outputDir, `${sanitizeCaptureName(name)}.png`) + + await mkdir(outputDir, { recursive: true }) + await page.screenshot({ + animations: 'disabled', + fullPage: options?.fullPage ?? false, + path: filePath, + }) + + return filePath +} diff --git a/packages/devtool-capture-stage-tamagotchi/src/runtime/context.ts b/packages/devtool-capture-stage-tamagotchi/src/runtime/context.ts new file mode 100644 index 000000000..f2f015134 --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/runtime/context.ts @@ -0,0 +1,61 @@ +import type { ElectronApplication, Page } from 'playwright' + +import type { CaptureOptions, ScenarioContext } from './types' + +import { dismissDialog, dismissDrawer, swipeDownDrawer } from '../utils/overlays' +import { expandControlsIsland, openChatFromControlsIsland, openHearingFromControlsIsland, openSettingsFromControlsIsland } from '../utils/selectors' +import { goToSettingsConnectionPage } from '../utils/settings' +import { waitForStageWindow } from '../utils/windows' +import { capturePage } from './capture' + +export function createScenarioContext(electronApp: ElectronApplication, outputDir: string): ScenarioContext { + return { + electronApp, + outputDir, + capture(name: string, page: Page, options?: CaptureOptions) { + return capturePage(outputDir, name, page, options) + }, + stageWindows: { + waitFor(name, timeout) { + return waitForStageWindow(electronApp, name, timeout) + }, + }, + controlsIsland: { + async expand(page) { + await expandControlsIsland(page) + }, + async openSettings(page) { + await openSettingsFromControlsIsland(page) + return waitForStageWindow(electronApp, 'settings') + }, + async openChat(page) { + await openChatFromControlsIsland(page) + return waitForStageWindow(electronApp, 'chat') + }, + openHearing(page) { + return openHearingFromControlsIsland(page) + }, + }, + settingsWindow: { + waitFor(timeout) { + return waitForStageWindow(electronApp, 'settings', timeout) + }, + goToConnection(page) { + return goToSettingsConnectionPage(page) + }, + }, + dialogs: { + dismiss(page) { + return dismissDialog(page) + }, + }, + drawers: { + swipeDown(page) { + return swipeDownDrawer(page) + }, + dismiss(page) { + return dismissDrawer(page) + }, + }, + } +} diff --git a/packages/devtool-capture-stage-tamagotchi/src/runtime/define-scenario.ts b/packages/devtool-capture-stage-tamagotchi/src/runtime/define-scenario.ts new file mode 100644 index 000000000..cf44c1a35 --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/runtime/define-scenario.ts @@ -0,0 +1,5 @@ +import type { StageTamagotchiScenario } from './types' + +export function defineScenario(scenario: StageTamagotchiScenario): StageTamagotchiScenario { + return scenario +} diff --git a/packages/devtool-capture-stage-tamagotchi/src/runtime/load-scenario.ts b/packages/devtool-capture-stage-tamagotchi/src/runtime/load-scenario.ts new file mode 100644 index 000000000..45e94d841 --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/runtime/load-scenario.ts @@ -0,0 +1,58 @@ +import type { StageTamagotchiScenario } from './types' + +import process from 'node:process' + +import { access } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +export interface LoadedScenarioModule { + modulePath: string + scenario: StageTamagotchiScenario +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + return String(error) +} + +function isStageTamagotchiScenario(value: unknown): value is StageTamagotchiScenario { + if (typeof value !== 'object' || value === null) { + return false + } + + const scenario = value as Partial + return typeof scenario.id === 'string' && typeof scenario.run === 'function' +} + +export async function loadScenarioModule(scenarioPath: string): Promise { + const modulePath = resolve(process.cwd(), scenarioPath) + + await access(modulePath).catch(() => { + throw new Error(`Scenario module not found at ${modulePath}`) + }) + + let moduleNamespace: { default?: unknown } + + try { + moduleNamespace = await import(pathToFileURL(modulePath).href) + } + catch (error) { + throw new Error(`Failed to load scenario module at ${modulePath}: ${getErrorMessage(error)}`) + } + + if (!isStageTamagotchiScenario(moduleNamespace.default)) { + const exportedKeys = Object.keys(moduleNamespace).join(', ') || '(none)' + throw new Error( + `Scenario module at ${modulePath} must export a default scenario object with id and run(ctx). Exported keys: ${exportedKeys}`, + ) + } + + return { + modulePath, + scenario: moduleNamespace.default, + } +} diff --git a/packages/devtool-capture-stage-tamagotchi/src/runtime/types.ts b/packages/devtool-capture-stage-tamagotchi/src/runtime/types.ts new file mode 100644 index 000000000..513658a5f --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/runtime/types.ts @@ -0,0 +1,48 @@ +import type { ElectronApplication, Page } from 'playwright' + +import type { StageWindowName, StageWindowSnapshot } from '../utils/windows' + +export interface CaptureOptions { + fullPage?: boolean +} + +export interface StageWindowsApi { + waitFor: (name: StageWindowName, timeout?: number) => Promise +} + +export interface ControlsIslandApi { + expand: (page: Page) => Promise + openSettings: (page: Page) => Promise + openChat: (page: Page) => Promise + openHearing: (page: Page) => Promise +} + +export interface SettingsWindowApi { + waitFor: (timeout?: number) => Promise + goToConnection: (page: Page) => Promise +} + +export interface DialogsApi { + dismiss: (page: Page) => Promise +} + +export interface DrawersApi { + swipeDown: (page: Page) => Promise + dismiss: (page: Page) => Promise +} + +export interface ScenarioContext { + electronApp: ElectronApplication + outputDir: string + capture: (name: string, page: Page, options?: CaptureOptions) => Promise + stageWindows: StageWindowsApi + controlsIsland: ControlsIslandApi + settingsWindow: SettingsWindowApi + dialogs: DialogsApi + drawers: DrawersApi +} + +export interface StageTamagotchiScenario { + id: string + run: (context: ScenarioContext) => Promise +} diff --git a/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-controls-settings-chat-websocket.ts b/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-controls-settings-chat-websocket.ts new file mode 100644 index 000000000..c7a00b41e --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-controls-settings-chat-websocket.ts @@ -0,0 +1,31 @@ +/* eslint-disable e18e/prefer-static-regex */ +import { defineScenario } from '../runtime/define-scenario' + +export default defineScenario({ + id: 'demo-controls-settings-chat-websocket', + async run({ capture, controlsIsland, settingsWindow, stageWindows }) { + const mainWindow = await stageWindows.waitFor('main') + + await controlsIsland.expand(mainWindow.page) + await mainWindow.page.waitForTimeout(300) + + await capture('00-controls-island-expanded', mainWindow.page) + + const settingsWindowSnapshot = await controlsIsland.openSettings(mainWindow.page) + await settingsWindowSnapshot.page.getByText(/connection|websocket|router/i).first().waitFor({ state: 'visible' }) + await settingsWindowSnapshot.page.waitForTimeout(300) + await capture('01-settings-window', settingsWindowSnapshot.page) + + await mainWindow.page.bringToFront() + await controlsIsland.expand(mainWindow.page) + const chatWindowSnapshot = await controlsIsland.openChat(mainWindow.page) + await chatWindowSnapshot.page.waitForLoadState('domcontentloaded') + await chatWindowSnapshot.page.waitForTimeout(300) + await capture('02-chat-window', chatWindowSnapshot.page) + + const websocketSettingsPage = await settingsWindow.goToConnection(settingsWindowSnapshot.page) + await websocketSettingsPage.getByText('WebSocket Server Address').waitFor({ state: 'visible' }) + await websocketSettingsPage.waitForTimeout(300) + await capture('03-websocket-settings', websocketSettingsPage) + }, +}) diff --git a/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-dismiss-surfaces.ts b/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-dismiss-surfaces.ts new file mode 100644 index 000000000..c3db44748 --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-dismiss-surfaces.ts @@ -0,0 +1,12 @@ +import { defineScenario } from '../runtime/define-scenario' + +export default defineScenario({ + id: 'demo-dismiss-surfaces', + async run({ dialogs, drawers, stageWindows }) { + const mainWindow = await stageWindows.waitFor('main') + + await dialogs.dismiss(mainWindow.page) + await drawers.swipeDown(mainWindow.page) + await drawers.dismiss(mainWindow.page) + }, +}) diff --git a/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-hearing-dialog.ts b/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-hearing-dialog.ts new file mode 100644 index 000000000..927c907a9 --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-hearing-dialog.ts @@ -0,0 +1,16 @@ +import { defineScenario } from '../runtime/define-scenario' + +export default defineScenario({ + id: 'demo-hearing-dialog', + async run({ capture, controlsIsland, stageWindows, drawers }) { + const mainWindow = await stageWindows.waitFor('main') + + const page = await controlsIsland.openHearing(mainWindow.page) + await page.waitForTimeout(1000) + await capture('hearing-dialog-open', page) + + await drawers.swipeDown(page) + await page.waitForTimeout(1000) + await capture('hearing-dialog-down', page) + }, +}) diff --git a/packages/devtool-capture-stage-tamagotchi/src/scenarios/settings-connection.ts b/packages/devtool-capture-stage-tamagotchi/src/scenarios/settings-connection.ts new file mode 100644 index 000000000..6b4d40f90 --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/scenarios/settings-connection.ts @@ -0,0 +1,16 @@ +import { defineScenario } from '../runtime/define-scenario' + +export default defineScenario({ + id: 'settings-connection', + async run({ capture, stageWindows, controlsIsland, settingsWindow }) { + const mainWindow = await stageWindows.waitFor('main') + + await controlsIsland.expand(mainWindow.page) + const settings = await controlsIsland.openSettings(mainWindow.page) + const page = await settingsWindow.goToConnection(settings.page) + await page.waitForTimeout(1000) + + await page.getByText('WebSocket Server Address').waitFor({ state: 'visible' }) + await capture('connection-settings', page) + }, +}) diff --git a/packages/devtool-capture-stage-tamagotchi/src/utils/app-path.ts b/packages/devtool-capture-stage-tamagotchi/src/utils/app-path.ts new file mode 100644 index 000000000..c5308ff19 --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/utils/app-path.ts @@ -0,0 +1,24 @@ +import { access } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export interface ElectronAppInfo { + repoRoot: string + mainEntrypoint: string +} + +export async function resolveElectronAppInfo(): Promise { + const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..') + const repoRoot = resolve(packageRoot, '..', '..') + const stageTamagotchiRoot = resolve(repoRoot, 'apps', 'stage-tamagotchi') + const mainEntrypoint = resolve(stageTamagotchiRoot, 'out', 'main', 'index.js') + + await access(mainEntrypoint).catch(() => { + throw new Error(`Built Electron entrypoint not found at ${mainEntrypoint}. Run "pnpm -F @proj-airi/stage-tamagotchi build" first.`) + }) + + return { + repoRoot, + mainEntrypoint, + } +} diff --git a/packages/devtool-capture-stage-tamagotchi/src/utils/overlays.ts b/packages/devtool-capture-stage-tamagotchi/src/utils/overlays.ts new file mode 100644 index 000000000..d48631b9f --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/utils/overlays.ts @@ -0,0 +1,94 @@ +import type { Page } from 'playwright' + +const overlayDismissWaitMs = 200 +const drawerSwipeDistancePx = 320 +const drawerSwipeTopInsetPx = 24 + +async function hasVisibleDialog(page: Page): Promise { + return page.locator('[role="dialog"]').evaluateAll((elements) => { + return elements.some((element) => { + const htmlElement = element as HTMLElement + const style = window.getComputedStyle(htmlElement) + return style.display !== 'none' && style.visibility !== 'hidden' && htmlElement.getBoundingClientRect().height > 0 + }) + }).catch(() => false) +} + +async function clickOverlayCorner(page: Page): Promise { + const viewport = page.viewportSize() + const x = 16 + const y = Math.max(16, Math.min((viewport?.height ?? 48) - 16, 48)) + + // NOTICE: AIRI dialogs and drawers render full-screen overlays, so a corner + // click is a practical generic dismiss fallback when a dedicated close affordance + // is not known ahead of time. + await page.mouse.click(x, y) +} + +async function getVisibleDialogBox(page: Page) { + const dialogs = page.locator('[role="dialog"]') + const count = await dialogs.count() + + for (let index = count - 1; index >= 0; index -= 1) { + const dialog = dialogs.nth(index) + if (await dialog.isVisible().catch(() => false)) { + return dialog.boundingBox() + } + } + + return null +} + +export async function dismissDialog(page: Page): Promise { + if (!await hasVisibleDialog(page)) { + return + } + + await page.keyboard.press('Escape').catch(() => undefined) + await page.waitForTimeout(overlayDismissWaitMs) + + if (!await hasVisibleDialog(page)) { + return + } + + await clickOverlayCorner(page) + await page.waitForTimeout(overlayDismissWaitMs) +} + +export async function swipeDownDrawer(page: Page): Promise { + const dialogBox = await getVisibleDialogBox(page) + if (!dialogBox) { + return + } + + const startX = dialogBox.x + (dialogBox.width / 2) + const startY = dialogBox.y + Math.min(drawerSwipeTopInsetPx, Math.max(dialogBox.height / 8, 12)) + const endY = Math.min(dialogBox.y + dialogBox.height - 8, startY + Math.min(drawerSwipeDistancePx, dialogBox.height * 0.6)) + + await page.mouse.move(startX, startY) + await page.mouse.down() + await page.mouse.move(startX, endY, { steps: 12 }) + await page.mouse.up() + await page.waitForTimeout(overlayDismissWaitMs) +} + +export async function dismissDrawer(page: Page): Promise { + if (!await hasVisibleDialog(page)) { + return + } + + await swipeDownDrawer(page) + if (!await hasVisibleDialog(page)) { + return + } + + await page.keyboard.press('Escape').catch(() => undefined) + await page.waitForTimeout(overlayDismissWaitMs) + + if (!await hasVisibleDialog(page)) { + return + } + + await clickOverlayCorner(page) + await page.waitForTimeout(overlayDismissWaitMs) +} diff --git a/packages/devtool-capture-stage-tamagotchi/src/utils/selectors.ts b/packages/devtool-capture-stage-tamagotchi/src/utils/selectors.ts new file mode 100644 index 000000000..35a14d4a9 --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/utils/selectors.ts @@ -0,0 +1,40 @@ +import type { Page } from 'playwright' + +function iconAttributeSelector(iconName: string): string { + return `[${iconName.replace(':', '\\:')}]` +} + +async function clickControlButtonByIcon(page: Page, iconName: string): Promise { + const button = page.locator('button').filter({ + has: page.locator(iconAttributeSelector(iconName)), + }).last() + + await button.waitFor({ state: 'visible', timeout: 15_000 }) + await button.click({ force: true }) +} + +export async function expandControlsIsland(page: Page): Promise { + await clickControlButtonByIcon(page, 'i-solar:alt-arrow-up-line-duotone') +} + +export async function openSettingsFromControlsIsland(page: Page): Promise { + await clickControlButtonByIcon(page, 'i-solar:settings-minimalistic-outline') +} + +export async function openChatFromControlsIsland(page: Page): Promise { + await clickControlButtonByIcon(page, 'i-solar:chat-line-line-duotone') +} + +export async function openHearingFromControlsIsland(page: Page): Promise { + const expandButton = page.locator('button').filter({ + has: page.locator(iconAttributeSelector('i-solar:alt-arrow-up-line-duotone')), + }).last() + + const hearingButton = expandButton.locator('xpath=ancestor::button[1]/following::button[1]').first() + + await hearingButton.waitFor({ state: 'visible', timeout: 15_000 }) + await hearingButton.click({ force: true }) + + await page.getByText('Input device').waitFor({ state: 'visible', timeout: 15_000 }) + return page +} diff --git a/packages/devtool-capture-stage-tamagotchi/src/utils/settings.ts b/packages/devtool-capture-stage-tamagotchi/src/utils/settings.ts new file mode 100644 index 000000000..397713dd6 --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/utils/settings.ts @@ -0,0 +1,132 @@ +/* eslint-disable e18e/prefer-static-regex */ +import type { ElectronApplication, Page } from 'playwright' + +import { expandControlsIsland, openSettingsFromControlsIsland } from './selectors' +import { waitForStageWindow } from './windows' + +const mainLoadingProbeSamples = 3 +const mainLoadingProbeIntervalMs = 250 + +function normalizeLabel(label: RegExp | string): string | RegExp { + return label +} + +function getSettingsSwitch(settingsPage: Page, label: RegExp | string) { + const labelLocator = settingsPage.getByText(normalizeLabel(label)).first() + const row = labelLocator.locator('xpath=ancestor::label[1]') + const button = row.locator('button[role="switch"]').first() + + return { labelLocator, row, button } +} + +export async function openSettingsConnectionPage(_mainPage: Page, settingsPage: Page): Promise { + if (!settingsPage.url().includes('#/settings/connection')) { + await settingsPage.getByText(/connection|websocket|router/i).first().click({ force: true }) + await settingsPage.waitForURL(/#\/settings\/connection/) + } +} + +export async function goToSettingsConnectionPage(settingsPage: Page): Promise { + if (!settingsPage.url().includes('#/settings/connection')) { + await settingsPage.getByText(/connection|websocket|router/i).first().click({ force: true }) + await settingsPage.waitForURL(/#\/settings\/connection/) + } + + return settingsPage +} + +async function navigatePageToConnectionSettings(page: Page): Promise { + await page.evaluate(() => { + window.location.hash = '#/settings/connection' + }) + await page.waitForURL(/#\/settings\/connection/) +} + +async function findOnboardingPage(electronApp: ElectronApplication): Promise { + for (const page of electronApp.windows()) { + if (page.url().includes('#/onboarding')) { + return page + } + } + + return null +} + +function isTimedOutWaitingForMainWindow(error: unknown): boolean { + return error instanceof Error && error.message === 'Timed out waiting for "main" window' +} + +async function isMainWindowStuckLoading(page: Page): Promise { + for (let index = 0; index < mainLoadingProbeSamples; index += 1) { + const bodyText = await page.locator('body').textContent().catch(() => '') || '' + if (!bodyText.includes('Loading...')) { + return false + } + + if (index < mainLoadingProbeSamples - 1) { + await page.waitForTimeout(mainLoadingProbeIntervalMs) + } + } + + return true +} + +export async function openConnectionSettingsWindow(electronApp: ElectronApplication): Promise { + let mainWindow: Awaited> | null = null + + try { + mainWindow = await waitForStageWindow(electronApp, 'main') + } + catch (error) { + if (!isTimedOutWaitingForMainWindow(error)) { + throw error + } + + const onboardingPage = await findOnboardingPage(electronApp) + if (!onboardingPage) { + throw new Error('Unable to reach the main window and no onboarding window was available for fallback navigation') + } + + // NOTICE: Some local app states keep the main route on Loading... while the + // onboarding renderer is still available. Routing that renderer directly to + // settings keeps the interaction testable without depending on the island. + await navigatePageToConnectionSettings(onboardingPage) + return onboardingPage + } + + if (await isMainWindowStuckLoading(mainWindow.page)) { + const onboardingPage = await findOnboardingPage(electronApp) + if (!onboardingPage) { + throw new Error('The main window was stuck on Loading... and no onboarding window was available for fallback navigation') + } + + await navigatePageToConnectionSettings(onboardingPage) + return onboardingPage + } + + await mainWindow.page.bringToFront() + await expandControlsIsland(mainWindow.page) + await openSettingsFromControlsIsland(mainWindow.page) + + const settingsWindow = await waitForStageWindow(electronApp, 'settings', 10_000) + await goToSettingsConnectionPage(settingsWindow.page) + return settingsWindow.page +} + +export async function toggleSettingsSwitchByLabel(settingsPage: Page, label: RegExp | string): Promise<{ before: string, after: string }> { + const { labelLocator, row, button } = getSettingsSwitch(settingsPage, label) + + await labelLocator.waitFor({ state: 'visible', timeout: 15_000 }) + await row.waitFor({ state: 'visible', timeout: 15_000 }) + + const before = (await button.getAttribute('aria-checked')) ?? (await button.getAttribute('data-state')) ?? '' + await button.click({ force: true }) + await settingsPage.waitForTimeout(300) + const after = (await button.getAttribute('aria-checked')) ?? (await button.getAttribute('data-state')) ?? '' + + if (before === after) { + throw new Error(`Custom switch state did not change for label ${String(label)}`) + } + + return { before, after } +} diff --git a/packages/devtool-capture-stage-tamagotchi/src/utils/windows.ts b/packages/devtool-capture-stage-tamagotchi/src/utils/windows.ts new file mode 100644 index 000000000..32b9ce00a --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/src/utils/windows.ts @@ -0,0 +1,89 @@ +import type { ElectronApplication, Page } from 'playwright' + +const stageWindowPollIntervalMs = 250 +const stageWindowActivationDelayMs = 750 +const stageWindowClassificationLoadStateTimeoutMs = 500 + +async function inferRoute(page: Page): Promise { + const url = page.url() + const hashIndex = url.indexOf('#') + if (hashIndex === -1) { + return '' + } + + const hash = url.slice(hashIndex + 1) + return hash.length > 0 ? hash : '/' +} + +async function classifyWindow(page: Page): Promise { + await page.waitForLoadState('domcontentloaded', { timeout: stageWindowClassificationLoadStateTimeoutMs }).catch(() => undefined) + + const title = await page.title() + const url = page.url() + const route = await inferRoute(page) + + if (url.includes('beat-sync.html') || title.includes('BeatSync') || url.startsWith('devtools://')) { + return null + } + + if (route === '/chat' || title === 'Chat') { + return { name: 'chat', page, title, route } + } + + if (route.startsWith('/settings') || title === 'Settings') { + return { name: 'settings', page, title, route } + } + + if (route.startsWith('/onboarding') || title === 'Welcome to AIRI') { + return null + } + + if (route === '/' || title === 'AIRI') { + return { name: 'main', page, title, route } + } + + const bodyText = await page.locator('body').textContent().catch(() => '') || '' + if (bodyText.includes('Open the DevTools to troubleshoot BeatSync')) { + return null + } + + if (bodyText.includes('Chat')) { + return { name: 'chat', page, title, route } + } + + if (bodyText.includes('Fade on Hover') || bodyText.includes('Open WebSocket settings')) { + return { name: 'main', page, title, route } + } + + return null +} + +export type StageWindowName = 'main' | 'settings' | 'chat' + +export interface StageWindowSnapshot { + name: StageWindowName + page: Page + title: string + route: string +} + +export async function waitForStageWindow(electronApp: ElectronApplication, name: StageWindowName, timeout = 30_000): Promise { + const deadline = Date.now() + timeout + + while (Date.now() < deadline) { + const windows = electronApp.windows() + + for (const page of windows) { + const classified = await classifyWindow(page) + if (classified?.name === name) { + await page.bringToFront() + await page.waitForTimeout(stageWindowActivationDelayMs) + return classified + } + } + + await new Promise(resolve => setTimeout(resolve, stageWindowPollIntervalMs)) + } + + throw new Error(`Timed out waiting for "${name}" window`) +} diff --git a/packages/devtool-capture-stage-tamagotchi/tsconfig.json b/packages/devtool-capture-stage-tamagotchi/tsconfig.json new file mode 100644 index 000000000..9a907f02b --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": [ + "node", + "playwright" + ], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/devtool-capture-stage-tamagotchi/vitest.config.ts b/packages/devtool-capture-stage-tamagotchi/vitest.config.ts new file mode 100644 index 000000000..647f39364 --- /dev/null +++ b/packages/devtool-capture-stage-tamagotchi/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7425af837..23181308f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ catalogs: knip: specifier: ^6.0.4 version: 6.0.4 + meow: + specifier: ^14.1.0 + version: 14.1.0 mkcert: specifier: ^3.2.0 version: 3.2.0 @@ -2051,7 +2054,7 @@ importers: devDependencies: '@vitest/browser-playwright': specifier: catalog:vitest - version: 4.1.1(bufferutil@4.1.0)(playwright@1.58.2)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.1) + version: 4.1.1(bufferutil@4.1.0)(playwright@1.59.0)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.1) vitest: specifier: catalog:vitest version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -2089,6 +2092,27 @@ importers: specifier: ^1.0.2 version: 1.0.2 + packages/devtool-capture-stage-tamagotchi: + devDependencies: + '@moeru/std': + specifier: 'catalog:' + version: 0.1.0-beta.17 + '@types/node': + specifier: ^24.12.0 + version: 24.12.0 + meow: + specifier: 'catalog:' + version: 14.1.0 + playwright: + specifier: ^1.56.1 + version: 1.59.0 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/electron-eventa: dependencies: '@moeru/eventa': @@ -3691,7 +3715,7 @@ importers: version: 2.0.1-rc.19(crossws@0.4.4(patch_hash=4d79ec736d10d2a81a9e2a31b067d43f0b6665122267981e652ab9923d165958)(srvx@0.11.13(patch_hash=c761d25a70e22a0925c88fe91a9dd1c3153dd341d8fd4f260166678156c4df2d))) playwright: specifier: ^1.58.2 - version: 1.58.2 + version: 1.59.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -13855,6 +13879,10 @@ packages: mediabunny@1.40.0: resolution: {integrity: sha512-UztWnjkA15yYxqq7AC8MSsU4U6FIFIBHvHV94pouvjxQa0y2pGvi1HPmrFcaYrRZAuSKTrdw45VXUIe3gEEpsA==} + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} @@ -14788,13 +14816,13 @@ packages: platform@1.3.6: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} - playwright-core@1.58.2: - resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + playwright-core@1.59.0: + resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==} engines: {node: '>=18'} hasBin: true - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + playwright@1.59.0: + resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==} engines: {node: '>=18'} hasBin: true @@ -23569,11 +23597,11 @@ snapshots: vite: 8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vue: 3.5.30(typescript@5.9.3) - '@vitest/browser-playwright@4.1.1(bufferutil@4.1.0)(playwright@1.58.2)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.1)': + '@vitest/browser-playwright@4.1.1(bufferutil@4.1.0)(playwright@1.59.0)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.1)': dependencies: '@vitest/browser': 4.1.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.1) '@vitest/mocker': 4.1.1(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - playwright: 1.58.2 + playwright: 1.59.0 tinyrainbow: 3.0.3 vitest: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: @@ -28611,6 +28639,8 @@ snapshots: '@types/dom-mediacapture-transform': 0.1.11 '@types/dom-webcodecs': 0.1.13 + meow@14.1.0: {} + merge-descriptors@1.0.3: {} merge-descriptors@2.0.0: {} @@ -29853,11 +29883,11 @@ snapshots: platform@1.3.6: {} - playwright-core@1.58.2: {} + playwright-core@1.59.0: {} - playwright@1.58.2: + playwright@1.59.0: dependencies: - playwright-core: 1.58.2 + playwright-core: 1.59.0 optionalDependencies: fsevents: 2.3.2 @@ -32455,7 +32485,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.12.0 - '@vitest/browser-playwright': 4.1.1(bufferutil@4.1.0)(playwright@1.58.2)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.1) + '@vitest/browser-playwright': 4.1.1(bufferutil@4.1.0)(playwright@1.59.0)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.1) jsdom: 27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 24d00485f..0ebfbf40b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -94,6 +94,7 @@ catalog: is-network-error: ^1.3.1 isolated-vm: ^6.1.2 knip: ^6.0.4 + meow: ^14.1.0 mkcert: ^3.2.0 nano-staged: ^0.9.0 nanoid: 5.1.6