style: prevent use of .ts, .js import

This commit is contained in:
Neko Ayaka
2026-08-05 20:34:14 +08:00
parent f2d411ea22
commit 774b2daf03
25 changed files with 499 additions and 42 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ButtonItem } from '../theme/config.ts' import type { ButtonItem } from '../theme/config'
defineProps<{ defineProps<{
item: ButtonItem item: ButtonItem
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ButtonItem } from '../theme/config.ts' import type { ButtonItem } from '../theme/config'
import HomeButton from './HomeButton.vue' import HomeButton from './HomeButton.vue'
+2 -2
View File
@@ -1,6 +1,6 @@
import type { DefaultTheme } from 'vitepress' import type { DefaultTheme } from 'vitepress'
import type { ThemeConfig } from './theme/config.ts' import type { ThemeConfig } from './theme/config'
import { join, posix, resolve } from 'node:path' import { join, posix, resolve } from 'node:path'
import { env } from 'node:process' import { env } from 'node:process'
@@ -15,7 +15,7 @@ import { tasklist } from '@mdit/plugin-tasklist'
import { defineConfig, postcssIsolateStyles } from 'vitepress' import { defineConfig, postcssIsolateStyles } from 'vitepress'
import { version } from '../../package.json' import { version } from '../../package.json'
import { webLive } from './constants.ts' import { webLive } from './constants'
import { teamMembers } from './contributors' import { teamMembers } from './contributors'
import { import {
discord, discord,
+18
View File
@@ -53,6 +53,15 @@ export default defineConfig({
selector: 'ConditionalExpression[test.type=\'BinaryExpression\'][test.operator=\'instanceof\'][test.right.name=\'Error\'][consequent.type=\'MemberExpression\'][consequent.property.name=\'message\']', selector: 'ConditionalExpression[test.type=\'BinaryExpression\'][test.operator=\'instanceof\'][test.right.name=\'Error\'][consequent.type=\'MemberExpression\'][consequent.property.name=\'message\']',
message: 'Avoid `error instanceof Error ? error.message : ...`. Use `errorMessageFrom(error)` from \'@moeru/std\' (or `errorMessageFromUnknown(error, fallback)` from \'@proj-airi/stage-shared\'). Pair with `?? \'fallback\'` when a default is needed.', message: 'Avoid `error instanceof Error ? error.message : ...`. Use `errorMessageFrom(error)` from \'@moeru/std\' (or `errorMessageFromUnknown(error, fallback)` from \'@proj-airi/stage-shared\'). Pair with `?? \'fallback\'` when a default is needed.',
}, },
{
selector: [
'ImportDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
'ExportNamedDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
'ExportAllDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
'ImportExpression[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
].join(', '),
message: 'Omit TypeScript and JavaScript source extensions from relative imports, dynamic imports, and re-exports.',
},
'TSEnumDeclaration[const=true]', 'TSEnumDeclaration[const=true]',
'TSExportAssignment', 'TSExportAssignment',
], ],
@@ -78,6 +87,15 @@ export default defineConfig({
selector: 'CallExpression[callee.type=\'MemberExpression\'][callee.object.name=\'vi\'][callee.property.name=\'hoisted\']', selector: 'CallExpression[callee.type=\'MemberExpression\'][callee.object.name=\'vi\'][callee.property.name=\'hoisted\']',
message: 'Do not use vi.hoisted. If a test needs a collaborator spy, expose an explicit dependency injection point instead of hoisting module mocks.', message: 'Do not use vi.hoisted. If a test needs a collaborator spy, expose an explicit dependency injection point instead of hoisting module mocks.',
}, },
{
selector: [
'ImportDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
'ExportNamedDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
'ExportAllDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
'ImportExpression[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
].join(', '),
message: 'Omit TypeScript and JavaScript source extensions from relative imports, dynamic imports, and re-exports.',
},
], ],
}, },
}, { }, {
@@ -35,6 +35,24 @@ Expected result:
- `27` raw files in `packages/scenarios-stage-tamagotchi-browser/artifacts/raw` - `27` raw files in `packages/scenarios-stage-tamagotchi-browser/artifacts/raw`
- names like `00-stage-tamagotchi.avif` ... `26-devtools-vision-capture.avif` - names like `00-stage-tamagotchi.avif` ... `26-devtools-vision-capture.avif`
### Capture a local display model
Use the file-input scenario when the requested evidence must render a local Live2D, VRM, or MMD model instead of a preset:
```bash
AIRI_DISPLAY_MODEL_FORMAT=vrm \
AIRI_DISPLAY_MODEL_PATH=/absolute/path/to/avatar.vrm \
pnpm exec vishot capture \
./packages/scenarios-stage-tamagotchi-electron/src/scenarios/display-model-from-file.ts \
--target electron \
--app-entrypoint ./apps/stage-tamagotchi/out/main/index.js \
--cwd . \
--settle-ms 2500 \
--output-dir ./.vishot/display-model/vrm
```
Set `AIRI_DISPLAY_MODEL_FORMAT` to `live2d`, `vrm`, or `mmd`. The scenario skips first-run onboarding in the capture profile, imports and selects the supplied file through the model chooser, captures the main stage, then restores persisted state and removes the imported fixture.
## Scenario Authoring ## Scenario Authoring
```ts ```ts
@@ -2,7 +2,7 @@ import type { ScenarioContext } from '@vishot/source-electron'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { createStageTamagotchiScenarioContext, defineStageTamagotchiScenario } from './context.ts' import { createStageTamagotchiScenarioContext, defineStageTamagotchiScenario } from './context'
function createElectronApp(): ScenarioContext['electronApp'] { function createElectronApp(): ScenarioContext['electronApp'] {
return Object.assign(Object.create(null), { return Object.assign(Object.create(null), {
@@ -1,14 +1,14 @@
import type { ElectronScenario, ScenarioContext } from '@vishot/source-electron' import type { ElectronScenario, ScenarioContext } from '@vishot/source-electron'
import type { Page } from 'playwright' import type { Page } from 'playwright'
import type { StageWindowName, StageWindowSnapshot } from './runtime/windows.ts' import type { StageWindowName, StageWindowSnapshot } from './runtime/windows'
import { defineScenario } from '@vishot/source-electron' import { defineScenario } from '@vishot/source-electron'
import { dismissDialog, dismissDrawer, swipeDownDrawer } from './runtime/overlays.ts' import { dismissDialog, dismissDrawer, swipeDownDrawer } from './runtime/overlays'
import { expandControlsIsland, openChatFromControlsIsland, openHearingFromControlsIsland, openSettingsFromControlsIsland, waitForControlsIslandReady } from './runtime/selectors.ts' import { expandControlsIsland, openChatFromControlsIsland, openHearingFromControlsIsland, openSettingsFromControlsIsland, waitForControlsIslandReady } from './runtime/selectors'
import { goToSettingsConnectionPage, goToSettingsRoute } from './runtime/settings.ts' import { goToSettingsConnectionPage, goToSettingsRoute } from './runtime/settings'
import { waitForStageWindow } from './runtime/windows.ts' import { waitForStageWindow } from './runtime/windows'
export interface StageWindowsApi { export interface StageWindowsApi {
waitFor: (name: StageWindowName, timeout?: number) => Promise<StageWindowSnapshot> waitFor: (name: StageWindowName, timeout?: number) => Promise<StageWindowSnapshot>
@@ -1,7 +1,17 @@
export { default as demoControlsSettingsChatWebsocketScenario } from './scenarios/demo-controls-settings-chat-websocket/index.ts' export { default as demoControlsSettingsChatWebsocketScenario } from './scenarios/demo-controls-settings-chat-websocket/index'
export { default as demoDismissSurfacesScenario } from './scenarios/demo-dismiss-surfaces.ts' export { default as demoDismissSurfacesScenario } from './scenarios/demo-dismiss-surfaces'
export { default as demoHearingDialogScenario } from './scenarios/demo-hearing-dialog.ts' export { default as demoHearingDialogScenario } from './scenarios/demo-hearing-dialog'
export { default as pluginChessWidgetFlowScenario } from './scenarios/plugin-chess-widget-flow.ts' export {
export { default as pluginChessWorkerSmokeScenario } from './scenarios/plugin-chess-worker-smoke.ts' default as displayModelFromFileScenario,
export { default as pluginWidgetStaticAssetsLocalAddressScenario } from './scenarios/plugin-widget-static-assets-local-address.ts' importDisplayModelFromFile,
export { default as settingsConnectionScenario } from './scenarios/settings-connection.ts' resolveDisplayModelInputFromEnvironment,
} from './scenarios/display-model-from-file'
export type {
DisplayModelInput,
DisplayModelInputFormat,
ImportedDisplayModel,
} from './scenarios/display-model-from-file'
export { default as pluginChessWidgetFlowScenario } from './scenarios/plugin-chess-widget-flow'
export { default as pluginChessWorkerSmokeScenario } from './scenarios/plugin-chess-worker-smoke'
export { default as pluginWidgetStaticAssetsLocalAddressScenario } from './scenarios/plugin-widget-static-assets-local-address'
export { default as settingsConnectionScenario } from './scenarios/settings-connection'
@@ -1,9 +1,9 @@
import type { ManualRuntime } from './shared/types.ts' import type { ManualRuntime } from './shared/types'
import { defineStageTamagotchiScenario } from '../../context.ts' import { defineStageTamagotchiScenario } from '../../context'
import { manualCaptureSections } from './manifest.ts' import { manualCaptureSections } from './manifest'
import { formatStepFailure, resetScenarioOutputDirectories } from './shared/output.ts' import { formatStepFailure, resetScenarioOutputDirectories } from './shared/output'
import { runCaptureStep } from './shared/steps.ts' import { runCaptureStep } from './shared/steps'
export default defineStageTamagotchiScenario({ export default defineStageTamagotchiScenario({
id: 'demo-controls-settings-chat-websocket', id: 'demo-controls-settings-chat-websocket',
@@ -1,6 +1,6 @@
import { devtoolsSection } from './sections/devtools.ts' import { devtoolsSection } from './sections/devtools'
import { overviewSection } from './sections/overview.ts' import { overviewSection } from './sections/overview'
import { settingsSection } from './sections/settings.ts' import { settingsSection } from './sections/settings'
export const manualCaptureSections = [ export const manualCaptureSections = [
overviewSection, overviewSection,
@@ -1,4 +1,4 @@
import type { ManualCaptureSection } from '../shared/types.ts' import type { ManualCaptureSection } from '../shared/types'
const useWindowMousePattern = /useWindowMouse|\d+,\s*\d+/i const useWindowMousePattern = /useWindowMouse|\d+,\s*\d+/i
const displaysPattern = /useElectronAllDisplays|@\s*\d+°|Visualize connected displays and cursor position/i const displaysPattern = /useElectronAllDisplays|@\s*\d+°|Visualize connected displays and cursor position/i
@@ -1,4 +1,4 @@
import type { ManualCaptureSection } from '../shared/types.ts' import type { ManualCaptureSection } from '../shared/types'
const websocketServerAddressPattern = /WebSocket Server Address|WebSocket 服务器地址/i const websocketServerAddressPattern = /WebSocket Server Address|WebSocket 服务器地址/i
@@ -1,4 +1,4 @@
import type { ManualCaptureSection } from '../shared/types.ts' import type { ManualCaptureSection } from '../shared/types'
const airiCardPattern = /sort|排序|upload|上传/i const airiCardPattern = /sort|排序|upload|上传/i
const providersPattern = /Chat|Speech|Transcription/i const providersPattern = /Chat|Speech|Transcription/i
@@ -2,7 +2,7 @@ import { mkdir, rm } from 'node:fs/promises'
import { errorMessageFrom } from '@moeru/std' import { errorMessageFrom } from '@moeru/std'
import { scenarioRawOutputDir } from './constants.ts' import { scenarioRawOutputDir } from './constants'
export async function resetScenarioOutputDirectories() { export async function resetScenarioOutputDirectories() {
await rm(scenarioRawOutputDir, { recursive: true, force: true }) await rm(scenarioRawOutputDir, { recursive: true, force: true })
@@ -1,4 +1,4 @@
import type { ManualRuntime } from './types.ts' import type { ManualRuntime } from './types'
import { sleep } from '@moeru/std' import { sleep } from '@moeru/std'
@@ -1,8 +1,8 @@
import type { CaptureExecutionResult, ManualCaptureStep, ManualRuntime } from './types.ts' import type { CaptureExecutionResult, ManualCaptureStep, ManualRuntime } from './types'
import { sleep } from '@moeru/std' import { sleep } from '@moeru/std'
import { ensureControlsIslandExpanded, getChatWindowSnapshot, getSettingsWindowSnapshot, waitForRouteReadiness } from './runtime.ts' import { ensureControlsIslandExpanded, getChatWindowSnapshot, getSettingsWindowSnapshot, waitForRouteReadiness } from './runtime'
async function captureStepPage(step: ManualCaptureStep, runtime: ManualRuntime): Promise<CaptureExecutionResult> { async function captureStepPage(step: ManualCaptureStep, runtime: ManualRuntime): Promise<CaptureExecutionResult> {
let page let page
@@ -1,6 +1,6 @@
import type { VishotArtifact } from '@vishot/source-electron' import type { VishotArtifact } from '@vishot/source-electron'
import type { StageTamagotchiScenarioContext } from '../../../context.ts' import type { StageTamagotchiScenarioContext } from '../../../context'
export type ManualSectionId = 'overview' | 'settings' | 'devtools' export type ManualSectionId = 'overview' | 'settings' | 'devtools'
export type ManualCaptureStepKind = 'main-window' | 'controls-island' | 'chat-window' | 'settings-overview' | 'settings-route' | 'connection' export type ManualCaptureStepKind = 'main-window' | 'controls-island' | 'chat-window' | 'settings-overview' | 'settings-route' | 'connection'
@@ -1,4 +1,4 @@
import { defineStageTamagotchiScenario } from '../context.ts' import { defineStageTamagotchiScenario } from '../context'
export default defineStageTamagotchiScenario({ export default defineStageTamagotchiScenario({
id: 'demo-dismiss-surfaces', id: 'demo-dismiss-surfaces',
@@ -1,4 +1,4 @@
import { defineStageTamagotchiScenario } from '../context.ts' import { defineStageTamagotchiScenario } from '../context'
export default defineStageTamagotchiScenario({ export default defineStageTamagotchiScenario({
id: 'demo-hearing-dialog', id: 'demo-hearing-dialog',
@@ -0,0 +1,44 @@
import type { ElectronApplication } from 'playwright'
import { describe, expect, it } from 'vitest'
import {
importDisplayModelFromFile,
resolveDisplayModelInputFromEnvironment,
} from './display-model-from-file'
describe('resolveDisplayModelInputFromEnvironment', () => {
it('resolves an explicit supported format and absolute input path', () => {
expect(resolveDisplayModelInputFromEnvironment({
AIRI_DISPLAY_MODEL_FORMAT: 'live2d',
AIRI_DISPLAY_MODEL_PATH: '/fixtures/avatar.zip',
})).toEqual({
format: 'live2d',
filePath: '/fixtures/avatar.zip',
})
})
it('rejects an unsupported model format before launching the scenario', () => {
expect(() => resolveDisplayModelInputFromEnvironment({
AIRI_DISPLAY_MODEL_FORMAT: 'gltf',
AIRI_DISPLAY_MODEL_PATH: '/fixtures/avatar.gltf',
})).toThrowError('AIRI_DISPLAY_MODEL_FORMAT must be live2d, vrm, or mmd')
})
it('requires the input path', () => {
expect(() => resolveDisplayModelInputFromEnvironment({
AIRI_DISPLAY_MODEL_FORMAT: 'vrm',
})).toThrowError('AIRI_DISPLAY_MODEL_PATH is required')
})
})
describe('importDisplayModelFromFile', () => {
it('rejects relative paths before touching Electron', async () => {
const electronApp = Object.create(null) as ElectronApplication
await expect(importDisplayModelFromFile(electronApp, {
format: 'mmd',
filePath: 'fixtures/avatar.pmx',
})).rejects.toThrowError('Display model input must be an absolute path')
})
})
@@ -0,0 +1,367 @@
import type { ElectronApplication, Page } from 'playwright'
import { env } from 'node:process'
import { defineStageTamagotchiScenario } from '../context'
import { waitForStageWindow } from '../runtime/windows'
export type DisplayModelInputFormat = 'live2d' | 'vrm' | 'mmd'
export interface DisplayModelInput {
/** Select the AIRI importer whose accepted extensions match the file. */
format: DisplayModelInputFormat
/** Provide an absolute path visible to the machine running Vishot. */
filePath: string
}
export interface ImportedDisplayModel {
id: string
mainPage: Page
settingsPage: Page
/** Restore persisted AIRI state and remove the imported IndexedDB fixture. */
cleanup: () => Promise<void>
}
const localStorageKeys = [
'airi-cards',
'onboarding/completed',
'onboarding/skipped',
'settings/stage/model',
] as const
const localforageDatabaseName = 'localforage'
const localforageStoreName = 'keyvaluepairs'
const importedModelKeyPrefix = 'display-model-'
const modelRoutePath = '/settings/models'
/**
* Imports and selects a display model through AIRI's user-facing model chooser.
*
* The returned cleanup owns every persisted value changed by this flow. Call it
* after capture so repeated scenarios do not inherit a prior imported fixture.
*/
export async function importDisplayModelFromFile(
electronApp: ElectronApplication,
input: DisplayModelInput,
): Promise<ImportedDisplayModel> {
if (!input.filePath.startsWith('/') && !/^[A-Z]:[\\/]/i.test(input.filePath))
throw new Error('Display model input must be an absolute path')
const mainPage = await findMainRenderer(electronApp)
const persistedState = await snapshotPersistedState(mainPage)
let settingsPage: Page | undefined
let importedModelId: string | undefined
try {
await markOnboardingCompleted(mainPage)
await closeOnboardingWindows(electronApp)
await mainPage.reload({ waitUntil: 'domcontentloaded' })
const mainWindow = await waitForStageWindow(electronApp, 'main')
const existingModelIds = await readImportedModelIds(mainWindow.page)
settingsPage = await openModelSettings(electronApp, mainWindow.page)
importedModelId = await uploadModel(settingsPage, input, existingModelIds)
await selectImportedModel(settingsPage, fileName(input.filePath))
await waitForSelectedModel(mainWindow.page, importedModelId)
const selectedModelId = importedModelId
const selectedSettingsPage = settingsPage
return {
id: selectedModelId,
mainPage: mainWindow.page,
settingsPage: selectedSettingsPage,
async cleanup() {
await restorePersistedState(selectedSettingsPage, persistedState)
await removeImportedModel(selectedSettingsPage, selectedModelId)
},
}
}
catch (error) {
const cleanupPage = settingsPage ?? mainPage
await restorePersistedState(cleanupPage, persistedState).catch(() => undefined)
if (importedModelId)
await removeImportedModel(cleanupPage, importedModelId).catch(() => undefined)
throw error
}
}
/**
* Resolves the model input contract used by the reusable Vishot scenario.
*
* @example
* // AIRI_DISPLAY_MODEL_FORMAT=vrm AIRI_DISPLAY_MODEL_PATH=/models/avatar.vrm
* resolveDisplayModelInputFromEnvironment()
* // => { format: 'vrm', filePath: '/models/avatar.vrm' }
*/
export function resolveDisplayModelInputFromEnvironment(
environment: Record<string, string | undefined> = env,
): DisplayModelInput {
const format = environment.AIRI_DISPLAY_MODEL_FORMAT
const filePath = environment.AIRI_DISPLAY_MODEL_PATH
if (format !== 'live2d' && format !== 'vrm' && format !== 'mmd')
throw new Error('AIRI_DISPLAY_MODEL_FORMAT must be live2d, vrm, or mmd')
if (!filePath)
throw new Error('AIRI_DISPLAY_MODEL_PATH is required')
return { format, filePath }
}
/**
* Captures AIRI after importing the model provided through environment variables.
*
* Call stack:
*
* displayModelFromFileScenario
* -> {@link resolveDisplayModelInputFromEnvironment}
* -> {@link importDisplayModelFromFile}
* -> AIRI model chooser
* -> Vishot capture
*/
const displayModelFromFileScenario = defineStageTamagotchiScenario({
id: 'display-model-from-file',
async run({ capture, electronApp }) {
const imported = await importDisplayModelFromFile(
electronApp,
resolveDisplayModelInputFromEnvironment(),
)
try {
await imported.mainPage.locator('canvas').first().waitFor({ state: 'visible', timeout: 30_000 })
await imported.mainPage.waitForTimeout(3_000)
await capture('display-model-from-file', imported.mainPage)
}
finally {
await imported.cleanup()
}
},
})
export default displayModelFromFileScenario
async function findMainRenderer(electronApp: ElectronApplication, timeout = 30_000): Promise<Page> {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
for (const page of electronApp.windows()) {
const route = hashRoute(page.url())
if (route === '/')
return page
}
await new Promise(resolve => setTimeout(resolve, 250))
}
throw new Error('Timed out waiting for the AIRI main renderer')
}
async function markOnboardingCompleted(page: Page): Promise<void> {
await page.evaluate(() => {
globalThis.localStorage.setItem('onboarding/completed', 'true')
globalThis.localStorage.setItem('onboarding/skipped', 'false')
})
}
async function closeOnboardingWindows(electronApp: ElectronApplication): Promise<void> {
for (const page of electronApp.windows()) {
if (hashRoute(page.url()).startsWith('/onboarding'))
await page.close().catch(() => undefined)
}
}
async function openModelSettings(electronApp: ElectronApplication, mainPage: Page): Promise<Page> {
const settingsButton = mainPage.locator('button').filter({
has: mainPage.locator('[i-solar\\:settings-minimalistic-outline]'),
}).first()
if (!await settingsButton.isVisible().catch(() => false)) {
await mainPage.locator('button').filter({
has: mainPage.locator('[i-solar\\:alt-arrow-up-line-duotone]'),
}).first().click({ force: true })
await settingsButton.waitFor({ state: 'visible', timeout: 15_000 })
}
await settingsButton.click({ force: true })
const settingsWindow = await waitForStageWindow(electronApp, 'settings')
const settingsPage = settingsWindow.page
const settingsContentSize = { width: 1200, height: 900 }
const nativeSettingsWindow = await electronApp.browserWindow(settingsPage)
try {
// A Playwright viewport override does not resize the native Electron window.
await nativeSettingsWindow.evaluate((window, size) => {
window.setContentSize(size.width, size.height)
}, settingsContentSize)
}
finally {
await nativeSettingsWindow.dispose()
}
await settingsPage.waitForFunction(size => (
globalThis.innerWidth === size.width
&& globalThis.innerHeight === size.height
), settingsContentSize)
await settingsPage.evaluate((routePath) => {
globalThis.location.hash = routePath
}, modelRoutePath)
await settingsPage.waitForURL(/#\/settings\/models/)
const selectorButton = settingsPage.getByRole('button').filter({
hasText: /select model|选择模型|選擇模型|モデルを選択|모델 선택/i,
}).first()
await selectorButton.waitFor({ state: 'visible', timeout: 30_000 })
await selectorButton.click()
return settingsPage
}
async function uploadModel(
settingsPage: Page,
input: DisplayModelInput,
existingModelIds: Set<string>,
): Promise<string> {
const optionsButton = settingsPage.getByRole('button', { name: 'Options for Display Models' }).first()
await optionsButton.waitFor({ state: 'visible', timeout: 15_000 })
await optionsButton.click()
let menuItemName = 'MMD'
if (input.format === 'live2d')
menuItemName = 'Live2D'
else if (input.format === 'vrm')
menuItemName = 'VRM'
const [fileChooser] = await Promise.all([
settingsPage.waitForEvent('filechooser'),
settingsPage.getByRole('menuitem', { name: menuItemName, exact: true }).click(),
])
await fileChooser.setFiles(input.filePath)
return waitForNewImportedModelId(settingsPage, existingModelIds)
}
async function selectImportedModel(settingsPage: Page, modelFileName: string): Promise<void> {
const modelName = settingsPage.getByText(modelFileName, { exact: true }).first()
await modelName.waitFor({ state: 'visible', timeout: 60_000 })
const card = modelName.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " block ")][1]')
await card.click()
const selectButton = card.locator('button').last()
await selectButton.waitFor({ state: 'visible', timeout: 15_000 })
await selectButton.click()
}
async function waitForSelectedModel(page: Page, importedModelId: string): Promise<void> {
await page.waitForFunction((modelId) => {
return globalThis.localStorage.getItem('settings/stage/model') === modelId
}, importedModelId, { timeout: 30_000 })
}
async function waitForNewImportedModelId(page: Page, existingModelIds: Set<string>, timeout = 60_000): Promise<string> {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const modelIds = await readImportedModelIds(page)
const importedModelId = [...modelIds].find(modelId => !existingModelIds.has(modelId))
if (importedModelId)
return importedModelId
await page.waitForTimeout(250)
}
throw new Error('Timed out waiting for the imported display model')
}
async function readImportedModelIds(page: Page): Promise<Set<string>> {
const keys = await page.evaluate(async ({ databaseName, storeName, keyPrefix }) => {
return new Promise<string[]>((resolve, reject) => {
const openRequest = globalThis.indexedDB.open(databaseName)
openRequest.onerror = () => reject(openRequest.error)
openRequest.onsuccess = () => {
const database = openRequest.result
if (!database.objectStoreNames.contains(storeName)) {
database.close()
resolve([])
return
}
const transaction = database.transaction(storeName, 'readonly')
const keysRequest = transaction.objectStore(storeName).getAllKeys()
keysRequest.onerror = () => reject(keysRequest.error)
keysRequest.onsuccess = () => {
database.close()
resolve(keysRequest.result.filter((key): key is string => typeof key === 'string' && key.startsWith(keyPrefix)))
}
}
})
}, {
databaseName: localforageDatabaseName,
storeName: localforageStoreName,
keyPrefix: importedModelKeyPrefix,
})
return new Set(keys)
}
async function removeImportedModel(page: Page, importedModelId: string): Promise<void> {
await page.evaluate(async ({ databaseName, storeName, modelId }) => {
await new Promise<void>((resolve, reject) => {
const openRequest = globalThis.indexedDB.open(databaseName)
openRequest.onerror = () => reject(openRequest.error)
openRequest.onsuccess = () => {
const database = openRequest.result
if (!database.objectStoreNames.contains(storeName)) {
database.close()
resolve()
return
}
const transaction = database.transaction(storeName, 'readwrite')
transaction.onerror = () => reject(transaction.error)
transaction.oncomplete = () => {
database.close()
resolve()
}
transaction.objectStore(storeName).delete(modelId)
}
})
}, {
databaseName: localforageDatabaseName,
storeName: localforageStoreName,
modelId: importedModelId,
})
}
async function snapshotPersistedState(page: Page): Promise<Record<string, string | null>> {
return page.evaluate((keys) => {
return Object.fromEntries(keys.map(key => [key, globalThis.localStorage.getItem(key)]))
}, localStorageKeys)
}
async function restorePersistedState(page: Page, state: Record<string, string | null>): Promise<void> {
await page.evaluate((persistedState) => {
for (const [key, value] of Object.entries(persistedState)) {
if (value === null)
globalThis.localStorage.removeItem(key)
else
globalThis.localStorage.setItem(key, value)
}
}, state)
}
function hashRoute(url: string): string {
const hashIndex = url.indexOf('#')
if (hashIndex === -1)
return ''
return url.slice(hashIndex + 1) || '/'
}
function fileName(filePath: string): string {
const normalizedPath = filePath.replaceAll('\\', '/')
return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1)
}
@@ -1,6 +1,6 @@
import type { StageTamagotchiScenarioContext } from '../context.ts' import type { StageTamagotchiScenarioContext } from '../context'
import { defineStageTamagotchiScenario } from '../context.ts' import { defineStageTamagotchiScenario } from '../context'
type ElectronApplication = StageTamagotchiScenarioContext['electronApp'] type ElectronApplication = StageTamagotchiScenarioContext['electronApp']
type Page = Parameters<StageTamagotchiScenarioContext['capture']>[1] type Page = Parameters<StageTamagotchiScenarioContext['capture']>[1]
@@ -1,6 +1,6 @@
import type { StageTamagotchiScenarioContext } from '../context.ts' import type { StageTamagotchiScenarioContext } from '../context'
import { defineStageTamagotchiScenario } from '../context.ts' import { defineStageTamagotchiScenario } from '../context'
type ElectronApplication = StageTamagotchiScenarioContext['electronApp'] type ElectronApplication = StageTamagotchiScenarioContext['electronApp']
type Page = Parameters<StageTamagotchiScenarioContext['capture']>[1] type Page = Parameters<StageTamagotchiScenarioContext['capture']>[1]
@@ -1,6 +1,6 @@
import type { StageTamagotchiScenarioContext } from '../context.ts' import type { StageTamagotchiScenarioContext } from '../context'
import { defineStageTamagotchiScenario } from '../context.ts' import { defineStageTamagotchiScenario } from '../context'
type ElectronApplication = StageTamagotchiScenarioContext['electronApp'] type ElectronApplication = StageTamagotchiScenarioContext['electronApp']
type Page = Parameters<StageTamagotchiScenarioContext['capture']>[1] type Page = Parameters<StageTamagotchiScenarioContext['capture']>[1]
@@ -1,4 +1,4 @@
import { defineStageTamagotchiScenario } from '../context.ts' import { defineStageTamagotchiScenario } from '../context'
const websocketServerAddressPattern = /WebSocket Server Address|WebSocket 服务器地址/i const websocketServerAddressPattern = /WebSocket Server Address|WebSocket 服务器地址/i