mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
style: prevent use of .ts, .js import
This commit is contained in:
@@ -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,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'
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
+5
-5
@@ -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',
|
||||||
|
|||||||
+3
-3
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
-1
@@ -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'
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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
-1
@@ -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',
|
||||||
|
|||||||
+44
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
+367
@@ -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)
|
||||||
|
}
|
||||||
+2
-2
@@ -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]
|
||||||
|
|||||||
+2
-2
@@ -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]
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user