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:
Neko
2026-04-02 15:22:37 +08:00
committed by GitHub
parent 5d30d6e295
commit eb448f6376
24 changed files with 1053 additions and 12 deletions
+1
View File
@@ -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>
}
@@ -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'],
},
})
+42 -12
View File
@@ -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
+1
View File
@@ -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