mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
feat(devtool-capture-stage-tamagotchi): tool for interacting the electron apps (#1549)
Authored-by-agent: Codex <267193182+codex@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 <scenario.ts> --output-dir <dir>')
|
||||
})
|
||||
|
||||
it('rejects missing output directory', () => {
|
||||
expect(() => parseCaptureCliArguments([
|
||||
'src/scenarios/settings-connection.ts',
|
||||
])).toThrow('Usage: capture <scenario.ts> --output-dir <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 <scenario.ts> --output-dir <dir>')
|
||||
})
|
||||
})
|
||||
@@ -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 <scenario.ts> --output-dir <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 <scenario.ts> --output-dir <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<void> {
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { defineScenario } from './runtime/define-scenario'
|
||||
export type {
|
||||
CaptureOptions,
|
||||
ControlsIslandApi,
|
||||
DialogsApi,
|
||||
DrawersApi,
|
||||
ScenarioContext,
|
||||
SettingsWindowApi,
|
||||
StageTamagotchiScenario,
|
||||
StageWindowsApi,
|
||||
} from './runtime/types'
|
||||
@@ -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<string> {
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { StageTamagotchiScenario } from './types'
|
||||
|
||||
export function defineScenario(scenario: StageTamagotchiScenario): StageTamagotchiScenario {
|
||||
return scenario
|
||||
}
|
||||
@@ -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<StageTamagotchiScenario>
|
||||
return typeof scenario.id === 'string' && typeof scenario.run === 'function'
|
||||
}
|
||||
|
||||
export async function loadScenarioModule(scenarioPath: string): Promise<LoadedScenarioModule> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<StageWindowSnapshot>
|
||||
}
|
||||
|
||||
export interface ControlsIslandApi {
|
||||
expand: (page: Page) => Promise<void>
|
||||
openSettings: (page: Page) => Promise<StageWindowSnapshot>
|
||||
openChat: (page: Page) => Promise<StageWindowSnapshot>
|
||||
openHearing: (page: Page) => Promise<Page>
|
||||
}
|
||||
|
||||
export interface SettingsWindowApi {
|
||||
waitFor: (timeout?: number) => Promise<StageWindowSnapshot>
|
||||
goToConnection: (page: Page) => Promise<Page>
|
||||
}
|
||||
|
||||
export interface DialogsApi {
|
||||
dismiss: (page: Page) => Promise<void>
|
||||
}
|
||||
|
||||
export interface DrawersApi {
|
||||
swipeDown: (page: Page) => Promise<void>
|
||||
dismiss: (page: Page) => Promise<void>
|
||||
}
|
||||
|
||||
export interface ScenarioContext {
|
||||
electronApp: ElectronApplication
|
||||
outputDir: string
|
||||
capture: (name: string, page: Page, options?: CaptureOptions) => Promise<string>
|
||||
stageWindows: StageWindowsApi
|
||||
controlsIsland: ControlsIslandApi
|
||||
settingsWindow: SettingsWindowApi
|
||||
dialogs: DialogsApi
|
||||
drawers: DrawersApi
|
||||
}
|
||||
|
||||
export interface StageTamagotchiScenario {
|
||||
id: string
|
||||
run: (context: ScenarioContext) => Promise<void>
|
||||
}
|
||||
+31
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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<ElectronAppInfo> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Page } from 'playwright'
|
||||
|
||||
const overlayDismissWaitMs = 200
|
||||
const drawerSwipeDistancePx = 320
|
||||
const drawerSwipeTopInsetPx = 24
|
||||
|
||||
async function hasVisibleDialog(page: Page): Promise<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await clickControlButtonByIcon(page, 'i-solar:alt-arrow-up-line-duotone')
|
||||
}
|
||||
|
||||
export async function openSettingsFromControlsIsland(page: Page): Promise<void> {
|
||||
await clickControlButtonByIcon(page, 'i-solar:settings-minimalistic-outline')
|
||||
}
|
||||
|
||||
export async function openChatFromControlsIsland(page: Page): Promise<void> {
|
||||
await clickControlButtonByIcon(page, 'i-solar:chat-line-line-duotone')
|
||||
}
|
||||
|
||||
export async function openHearingFromControlsIsland(page: Page): Promise<Page> {
|
||||
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
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<Page> {
|
||||
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<void> {
|
||||
await page.evaluate(() => {
|
||||
window.location.hash = '#/settings/connection'
|
||||
})
|
||||
await page.waitForURL(/#\/settings\/connection/)
|
||||
}
|
||||
|
||||
async function findOnboardingPage(electronApp: ElectronApplication): Promise<Page | null> {
|
||||
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<boolean> {
|
||||
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<Page> {
|
||||
let mainWindow: Awaited<ReturnType<typeof waitForStageWindow>> | 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 }
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<StageWindowSnapshot | null> {
|
||||
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<StageWindowSnapshot> {
|
||||
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`)
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Generated
+42
-12
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user