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">
|
||||
import type { ButtonItem } from '../theme/config.ts'
|
||||
import type { ButtonItem } from '../theme/config'
|
||||
|
||||
defineProps<{
|
||||
item: ButtonItem
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ButtonItem } from '../theme/config.ts'
|
||||
import type { ButtonItem } from '../theme/config'
|
||||
|
||||
import HomeButton from './HomeButton.vue'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { env } from 'node:process'
|
||||
@@ -15,7 +15,7 @@ import { tasklist } from '@mdit/plugin-tasklist'
|
||||
import { defineConfig, postcssIsolateStyles } from 'vitepress'
|
||||
|
||||
import { version } from '../../package.json'
|
||||
import { webLive } from './constants.ts'
|
||||
import { webLive } from './constants'
|
||||
import { teamMembers } from './contributors'
|
||||
import {
|
||||
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\']',
|
||||
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]',
|
||||
'TSExportAssignment',
|
||||
],
|
||||
@@ -78,6 +87,15 @@ export default defineConfig({
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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`
|
||||
- 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
|
||||
|
||||
```ts
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ScenarioContext } from '@vishot/source-electron'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createStageTamagotchiScenarioContext, defineStageTamagotchiScenario } from './context.ts'
|
||||
import { createStageTamagotchiScenarioContext, defineStageTamagotchiScenario } from './context'
|
||||
|
||||
function createElectronApp(): ScenarioContext['electronApp'] {
|
||||
return Object.assign(Object.create(null), {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { ElectronScenario, ScenarioContext } from '@vishot/source-electron'
|
||||
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 { dismissDialog, dismissDrawer, swipeDownDrawer } from './runtime/overlays.ts'
|
||||
import { expandControlsIsland, openChatFromControlsIsland, openHearingFromControlsIsland, openSettingsFromControlsIsland, waitForControlsIslandReady } from './runtime/selectors.ts'
|
||||
import { goToSettingsConnectionPage, goToSettingsRoute } from './runtime/settings.ts'
|
||||
import { waitForStageWindow } from './runtime/windows.ts'
|
||||
import { dismissDialog, dismissDrawer, swipeDownDrawer } from './runtime/overlays'
|
||||
import { expandControlsIsland, openChatFromControlsIsland, openHearingFromControlsIsland, openSettingsFromControlsIsland, waitForControlsIslandReady } from './runtime/selectors'
|
||||
import { goToSettingsConnectionPage, goToSettingsRoute } from './runtime/settings'
|
||||
import { waitForStageWindow } from './runtime/windows'
|
||||
|
||||
export interface StageWindowsApi {
|
||||
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 demoDismissSurfacesScenario } from './scenarios/demo-dismiss-surfaces.ts'
|
||||
export { default as demoHearingDialogScenario } from './scenarios/demo-hearing-dialog.ts'
|
||||
export { default as pluginChessWidgetFlowScenario } from './scenarios/plugin-chess-widget-flow.ts'
|
||||
export { default as pluginChessWorkerSmokeScenario } from './scenarios/plugin-chess-worker-smoke.ts'
|
||||
export { default as pluginWidgetStaticAssetsLocalAddressScenario } from './scenarios/plugin-widget-static-assets-local-address.ts'
|
||||
export { default as settingsConnectionScenario } from './scenarios/settings-connection.ts'
|
||||
export { default as demoControlsSettingsChatWebsocketScenario } from './scenarios/demo-controls-settings-chat-websocket/index'
|
||||
export { default as demoDismissSurfacesScenario } from './scenarios/demo-dismiss-surfaces'
|
||||
export { default as demoHearingDialogScenario } from './scenarios/demo-hearing-dialog'
|
||||
export {
|
||||
default as displayModelFromFileScenario,
|
||||
importDisplayModelFromFile,
|
||||
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 { manualCaptureSections } from './manifest.ts'
|
||||
import { formatStepFailure, resetScenarioOutputDirectories } from './shared/output.ts'
|
||||
import { runCaptureStep } from './shared/steps.ts'
|
||||
import { defineStageTamagotchiScenario } from '../../context'
|
||||
import { manualCaptureSections } from './manifest'
|
||||
import { formatStepFailure, resetScenarioOutputDirectories } from './shared/output'
|
||||
import { runCaptureStep } from './shared/steps'
|
||||
|
||||
export default defineStageTamagotchiScenario({
|
||||
id: 'demo-controls-settings-chat-websocket',
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import { devtoolsSection } from './sections/devtools.ts'
|
||||
import { overviewSection } from './sections/overview.ts'
|
||||
import { settingsSection } from './sections/settings.ts'
|
||||
import { devtoolsSection } from './sections/devtools'
|
||||
import { overviewSection } from './sections/overview'
|
||||
import { settingsSection } from './sections/settings'
|
||||
|
||||
export const manualCaptureSections = [
|
||||
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 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
|
||||
|
||||
|
||||
+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 providersPattern = /Chat|Speech|Transcription/i
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { mkdir, rm } from 'node:fs/promises'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
import { scenarioRawOutputDir } from './constants.ts'
|
||||
import { scenarioRawOutputDir } from './constants'
|
||||
|
||||
export async function resetScenarioOutputDirectories() {
|
||||
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'
|
||||
|
||||
|
||||
+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 { ensureControlsIslandExpanded, getChatWindowSnapshot, getSettingsWindowSnapshot, waitForRouteReadiness } from './runtime.ts'
|
||||
import { ensureControlsIslandExpanded, getChatWindowSnapshot, getSettingsWindowSnapshot, waitForRouteReadiness } from './runtime'
|
||||
|
||||
async function captureStepPage(step: ManualCaptureStep, runtime: ManualRuntime): Promise<CaptureExecutionResult> {
|
||||
let page
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
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 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({
|
||||
id: 'demo-dismiss-surfaces',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineStageTamagotchiScenario } from '../context.ts'
|
||||
import { defineStageTamagotchiScenario } from '../context'
|
||||
|
||||
export default defineStageTamagotchiScenario({
|
||||
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 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 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 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user