279 lines
16 KiB
JavaScript
279 lines
16 KiB
JavaScript
import { strict as assert } from 'node:assert'
|
|
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { _electron as electron } from 'playwright'
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
|
const packageRoot = resolve(scriptDir, '..')
|
|
const repoRoot = resolve(packageRoot, '../..')
|
|
const screenshotPath = String(process.env.OPENSQUILLA_DESKTOP_ONBOARDING_SCREENSHOT || '').trim()
|
|
|
|
async function waitFor(check, label, timeoutMs = 60_000) {
|
|
const startedAt = Date.now()
|
|
let lastError
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
try {
|
|
const value = await check()
|
|
if (value) return value
|
|
} catch (error) {
|
|
lastError = error
|
|
}
|
|
await delay(250)
|
|
}
|
|
const suffix = lastError ? ` Last error: ${lastError.message || lastError}` : ''
|
|
throw new Error(`Timed out waiting for ${label}.${suffix}`)
|
|
}
|
|
|
|
async function setupWindow(app) {
|
|
return await waitFor(async () => {
|
|
for (const page of app.windows()) {
|
|
if (page.isClosed()) continue
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
|
|
const hasSetupForm = await page.locator('#setup-form').count().catch(() => 0)
|
|
if (hasSetupForm > 0) return page
|
|
}
|
|
return null
|
|
}, 'desktop onboarding window')
|
|
}
|
|
|
|
const userDataRoot = await mkdtemp(join(tmpdir(), 'opensquilla-electron-onboarding-test-'))
|
|
const userDataDir = join(userDataRoot, 'chromium-user-data')
|
|
const isolatedHome = join(userDataRoot, 'home')
|
|
await mkdir(isolatedHome, { recursive: true })
|
|
const app = await electron.launch({
|
|
args: [
|
|
'--use-mock-keychain',
|
|
`--user-data-dir=${userDataDir}`,
|
|
packageRoot,
|
|
],
|
|
env: {
|
|
...process.env,
|
|
HOME: isolatedHome,
|
|
USERPROFILE: isolatedHome,
|
|
OPENSQUILLA_DESKTOP_REPO_ROOT: repoRoot,
|
|
OPENSQUILLA_DESKTOP_SECRET_STORAGE: 'plain',
|
|
OPENSQUILLA_DESKTOP_GATEWAY_PORT: '18897',
|
|
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE: '1',
|
|
OPENSQUILLA_DESKTOP_MOCK_UPDATE_VERSION: '',
|
|
LANG: 'en_US.UTF-8',
|
|
LC_ALL: 'en_US.UTF-8',
|
|
},
|
|
})
|
|
|
|
try {
|
|
const page = await setupWindow(app)
|
|
|
|
await page.locator('#onboardingLocale').selectOption('zh-Hans')
|
|
assert.equal(await page.evaluate(() => document.documentElement.lang), 'zh-Hans')
|
|
assert.equal(await page.locator('[data-screen="0"] h2').innerText(), '选择设置深度')
|
|
assert.equal(await page.title(), '设置 OpenSquilla')
|
|
assert.doesNotMatch(await page.locator('[data-setup-mode="advanced"]').innerText(), /Smart Router mode/)
|
|
await page.locator('[data-setup-mode="advanced"]').click()
|
|
|
|
await page.locator('[data-screen="0"].active .next-button').click()
|
|
await page.locator('[data-screen="1"].active').waitFor({ state: 'visible', timeout: 10_000 })
|
|
assert.equal(await page.locator('[data-step-label="2"]').count(), 1, 'advanced setup should expose the routing-mode progress step')
|
|
|
|
assert.equal(await page.locator('#provider').inputValue(), 'tokenrhythm')
|
|
assert.equal(await page.locator('#baseUrl').inputValue(), 'https://tokenrhythm.studio/v1')
|
|
assert.equal(await page.locator('#model').inputValue(), 'deepseek-v4-pro')
|
|
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'squilla_router')
|
|
assert.equal(await page.locator('#routerMode').inputValue(), 'recommended')
|
|
|
|
const tokenRhythmFeature = page.locator('[data-provider-feature="tokenrhythm"]')
|
|
assert.equal(await tokenRhythmFeature.count(), 1)
|
|
assert.equal(await tokenRhythmFeature.locator('[data-tokenrhythm-title]').innerText(), '推荐使用 TokenRhythm')
|
|
assert.equal(
|
|
await tokenRhythmFeature.locator('[data-tokenrhythm-value]').innerText(),
|
|
'TokenRhythm API 调用限时免费。',
|
|
)
|
|
assert.equal(
|
|
await tokenRhythmFeature.locator('[data-tokenrhythm-registration]').innerText(),
|
|
'活动期间,注册并获取 API Key,即可免费调用 DeepSeek、GLM、MiniMax、Kimi 等主流模型。',
|
|
)
|
|
const tokenRhythmCta = tokenRhythmFeature.locator('#tokenrhythmRegister')
|
|
assert.equal(await tokenRhythmCta.innerText(), '注册并获取 API Key')
|
|
assert.equal(await tokenRhythmCta.getAttribute('href'), 'https://tokenrhythm.studio/register')
|
|
assert.equal(await tokenRhythmCta.getAttribute('target'), '_blank')
|
|
assert.equal(await tokenRhythmCta.getAttribute('rel'), 'noopener noreferrer')
|
|
assert.equal(
|
|
await tokenRhythmCta.getAttribute('aria-label'),
|
|
'注册并获取 API Key — TokenRhythm(在外部浏览器中打开)',
|
|
)
|
|
assert.equal(await tokenRhythmFeature.locator('img, svg, canvas').count(), 0)
|
|
assert.equal(await tokenRhythmFeature.locator('[data-provider="tokenrhythm"]').getAttribute('aria-pressed'), 'true')
|
|
|
|
const providerMoreToggle = page.locator('#providerMoreToggle')
|
|
const providerMorePanel = page.locator('#providerMorePanel')
|
|
assert.equal(await providerMoreToggle.getAttribute('aria-expanded'), 'false')
|
|
assert.equal(await providerMoreToggle.getAttribute('aria-controls'), 'providerMorePanel')
|
|
assert.equal(await providerMorePanel.isHidden(), true)
|
|
|
|
await page.locator('#onboardingLocale').selectOption('en')
|
|
assert.equal(await page.evaluate(() => document.documentElement.lang), 'en')
|
|
assert.equal(await page.locator('[data-screen="1"] h2').innerText(), 'Connect a provider')
|
|
assert.equal(await page.locator('#provider').inputValue(), 'tokenrhythm', 'locale changes should preserve the selected provider')
|
|
assert.equal(await tokenRhythmFeature.locator('[data-tokenrhythm-title]').innerText(), 'Recommended: TokenRhythm')
|
|
assert.equal(
|
|
await tokenRhythmFeature.locator('[data-tokenrhythm-value]').innerText(),
|
|
'TokenRhythm API calls are free for a limited time.',
|
|
)
|
|
assert.equal(
|
|
await tokenRhythmFeature.locator('[data-tokenrhythm-registration]').innerText(),
|
|
'During the promotion, register and get an API key to call DeepSeek, GLM, MiniMax, Kimi, and other leading models for free.',
|
|
)
|
|
assert.equal(await tokenRhythmCta.innerText(), 'Register and get an API key')
|
|
assert.equal(
|
|
await tokenRhythmCta.getAttribute('aria-label'),
|
|
'Register and get an API key — TokenRhythm (opens in external browser)',
|
|
)
|
|
|
|
await providerMoreToggle.click()
|
|
assert.equal(await providerMoreToggle.getAttribute('aria-expanded'), 'true')
|
|
assert.equal(await providerMorePanel.isVisible(), true)
|
|
const openRouterProvider = page.locator('#providerGrid [data-provider="openrouter"]')
|
|
await openRouterProvider.click()
|
|
assert.equal(await page.locator('#provider').inputValue(), 'openrouter')
|
|
assert.equal(await openRouterProvider.getAttribute('aria-pressed'), 'true')
|
|
assert.equal(await tokenRhythmFeature.locator('[data-provider="tokenrhythm"]').getAttribute('aria-pressed'), 'false')
|
|
await page.locator('#onboardingLocale').selectOption('zh-Hans')
|
|
assert.equal(await page.locator('#provider').inputValue(), 'openrouter', 'locale changes should preserve another provider selection')
|
|
await page.locator('#onboardingLocale').selectOption('en')
|
|
assert.equal(await page.locator('#provider').inputValue(), 'openrouter')
|
|
await tokenRhythmFeature.locator('[data-provider="tokenrhythm"]').click()
|
|
assert.equal(await page.locator('#provider').inputValue(), 'tokenrhythm', 'TokenRhythm should remain re-selectable')
|
|
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'squilla_router')
|
|
assert.equal(await tokenRhythmFeature.locator('[data-provider="tokenrhythm"]').getAttribute('aria-pressed'), 'true')
|
|
await page.locator('#onboardingLocale').selectOption('zh-Hans')
|
|
await page.locator('#apiKey').fill('synthetic-tokenrhythm-key')
|
|
await page.locator('[data-screen="1"].active .next-button').click()
|
|
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 10_000 })
|
|
await page.waitForTimeout(300)
|
|
assert.equal(await page.locator('[data-screen="2"] h2').innerText(), '选择路由模式')
|
|
assert.equal(await page.locator('[data-model-routing-mode="squilla_router"]').isEnabled(), true)
|
|
assert.equal(await page.locator('[data-model-routing-mode="direct"]').isEnabled(), true)
|
|
assert.equal(await page.locator('[data-model-routing-mode="llm_ensemble"]').isEnabled(), true)
|
|
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'squilla_router')
|
|
assert.match(
|
|
await page.locator('[data-model-routing-mode="squilla_router"] small').innerText(),
|
|
/此提供商现有的 Squilla Router 层级默认值/,
|
|
)
|
|
assert.match(
|
|
await page.locator('[data-model-routing-mode="llm_ensemble"] small').innerText(),
|
|
/当前提供商的 static B5 Ensemble/,
|
|
)
|
|
if (screenshotPath) {
|
|
await mkdir(dirname(screenshotPath), { recursive: true })
|
|
await page.screenshot({ path: screenshotPath })
|
|
}
|
|
await page.locator('[data-screen="2"].active .next-button').click()
|
|
await page.locator('[data-screen="3"].active').waitFor({ state: 'visible', timeout: 10_000 })
|
|
assert.equal(await page.locator('[data-screen="3"] h2').innerText(), '候选模型池')
|
|
const tokenRhythmTierText = await page.locator('#tierBody').innerText()
|
|
for (const modelId of ['deepseek-v4-flash', 'deepseek-v4-pro', 'kimi-k2.7-code', 'glm-5.2', 'kimi-k2.6']) {
|
|
assert.match(tokenRhythmTierText, new RegExp(modelId.replaceAll('.', '\\.')))
|
|
}
|
|
await page.locator('[data-screen="3"].active .back-button').click()
|
|
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 5_000 })
|
|
await page.locator('[data-model-routing-mode="direct"]').click()
|
|
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'direct')
|
|
assert.equal(await page.locator('#directModelRoute').inputValue(), 'deepseek-v4-pro')
|
|
await page.locator('#directModelRoute').fill('glm-5.2')
|
|
assert.equal(await page.locator('#model').inputValue(), 'glm-5.2')
|
|
await page.locator('[data-model-routing-mode="llm_ensemble"]').click()
|
|
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'llm_ensemble')
|
|
await page.locator('[data-screen="2"].active .next-button').click()
|
|
await page.locator('[data-screen="4"].active').waitFor({ state: 'visible', timeout: 10_000 })
|
|
await page.locator('[data-screen="4"].active .back-button').click()
|
|
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 5_000 })
|
|
await page.locator('[data-screen="2"].active .back-button').click()
|
|
await page.locator('[data-screen="1"].active').waitFor({ state: 'visible', timeout: 5_000 })
|
|
await page.locator('#onboardingLocale').selectOption('en')
|
|
|
|
await page.locator('#providerGrid [data-provider="ollama"]').click()
|
|
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'direct')
|
|
assert.equal(await page.locator('#routerMode').inputValue(), 'disabled')
|
|
assert.equal(await page.locator('#model').inputValue(), '', 'direct-only providers without a default model should not inherit the previous provider model')
|
|
assert.equal(await page.locator('#endpointToggle').getAttribute('aria-expanded'), 'true', 'direct-only providers that need a model should open the endpoint panel')
|
|
await page.locator('[data-screen="1"].active .next-button').click()
|
|
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 5_000 })
|
|
assert.equal(await page.locator('[data-model-routing-mode="squilla_router"]').isDisabled(), true)
|
|
assert.equal(await page.locator('[data-model-routing-mode="direct"]').isEnabled(), true)
|
|
assert.equal(await page.locator('[data-model-routing-mode="llm_ensemble"]').isDisabled(), true)
|
|
assert.equal(await page.locator('[data-step-label="3"]').isVisible(), false, 'route-excluded tier step should be hidden from the progress rail')
|
|
await page.locator('[data-screen="2"].active .next-button').click()
|
|
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 5_000 })
|
|
assert.match(await page.locator('#error').innerText(), /Direct model is required/)
|
|
await page.locator('[data-screen="2"].active .back-button').click()
|
|
await page.locator('[data-screen="1"].active').waitFor({ state: 'visible', timeout: 5_000 })
|
|
|
|
await page.locator('#providerGrid [data-provider="openai"]').click()
|
|
|
|
assert.equal(await page.locator('#provider').inputValue(), 'openai')
|
|
assert.equal(await page.locator('#baseUrl').inputValue(), 'https://api.openai.com/v1')
|
|
assert.equal(await page.locator('#model').inputValue(), 'gpt-5.4-mini')
|
|
await page.locator('#providerGrid [data-provider="openai"].active').waitFor({ state: 'visible', timeout: 5_000 })
|
|
const openAiHint = await page.locator('#providerHint').innerText()
|
|
assert.match(openAiHint, /OpenAI-only tier profile/)
|
|
assert.doesNotMatch(openAiHint, /OPENAI_API_KEY/)
|
|
await page.locator('#apiKey').fill('test-openai-key')
|
|
await page.locator('[data-screen="1"].active .next-button').click()
|
|
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 10_000 })
|
|
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'squilla_router')
|
|
assert.equal(await page.locator('[data-model-routing-mode="squilla_router"]').isEnabled(), true)
|
|
assert.equal(await page.locator('[data-model-routing-mode="direct"]').isEnabled(), true)
|
|
assert.equal(await page.locator('[data-model-routing-mode="llm_ensemble"]').isDisabled(), true)
|
|
await page.locator('[data-screen="2"].active .next-button').click()
|
|
await page.locator('[data-screen="3"].active').waitFor({ state: 'visible', timeout: 10_000 })
|
|
assert.match(await page.locator('[data-screen="3"] .eyebrow').innerText(), /step 04/i)
|
|
assert.equal(await page.locator('[data-screen="3"] h2').innerText(), 'Review tier models')
|
|
|
|
await page.locator('[data-screen="3"].active .back-button').click()
|
|
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 5_000 })
|
|
await page.locator('[data-screen="2"].active .back-button').click()
|
|
await page.locator('[data-screen="1"].active').waitFor({ state: 'visible', timeout: 5_000 })
|
|
await tokenRhythmFeature.locator('[data-provider="tokenrhythm"]').click()
|
|
await page.locator('#apiKey').fill('synthetic-tokenrhythm-key')
|
|
await page.locator('[data-screen="1"].active .next-button').click()
|
|
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 5_000 })
|
|
await page.locator('[data-model-routing-mode="llm_ensemble"]').click()
|
|
await page.locator('[data-screen="2"].active .next-button').click()
|
|
await page.locator('[data-screen="4"].active').waitFor({ state: 'visible', timeout: 5_000 })
|
|
await page.locator('#finish').click()
|
|
const saved = await waitFor(async () => {
|
|
const credential = JSON.parse(await readFile(join(userDataDir, 'desktop-credential.json'), 'utf8'))
|
|
if (credential.modelRoutingMode !== 'llm_ensemble') return null
|
|
const config = await readFile(join(userDataDir, 'opensquilla', 'config.toml'), 'utf8')
|
|
return { credential, config }
|
|
}, 'saved ensemble credential and config')
|
|
const { credential, config } = saved
|
|
assert.equal(credential.provider, 'tokenrhythm')
|
|
assert.equal(credential.modelRoutingMode, 'llm_ensemble')
|
|
assert.equal(credential.routerMode, 'recommended')
|
|
assert.equal(credential.routerTiers.c0.model, 'deepseek-v4-flash')
|
|
assert.equal(credential.routerTiers.c1.model, 'deepseek-v4-pro')
|
|
assert.equal(credential.routerTiers.c2.model, 'kimi-k2.7-code')
|
|
assert.equal(credential.routerTiers.c3.model, 'glm-5.2')
|
|
assert.equal(credential.routerTiers.image_model.model, 'kimi-k2.6')
|
|
assert.match(config, /\[squilla_router\]\nenabled = true/)
|
|
assert.doesNotMatch(config, /tier_profile = "tokenrhythm"/)
|
|
assert.match(config, /\[squilla_router\.tiers\.c0\]\nprovider = "tokenrhythm"\nmodel = "deepseek-v4-flash"/)
|
|
assert.match(config, /\[squilla_router\.tiers\.c3\]\nprovider = "tokenrhythm"\nmodel = "glm-5\.2"/)
|
|
assert.match(config, /\[llm_ensemble\]\nenabled = true\nselection_mode = "static_tokenrhythm_b5"/)
|
|
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
provider: credential.provider,
|
|
modelRoutingMode: credential.modelRoutingMode,
|
|
routerMode: credential.routerMode,
|
|
model: credential.model,
|
|
screenshotPath: screenshotPath || null,
|
|
}, null, 2))
|
|
} finally {
|
|
await app.close().catch(() => {})
|
|
await rm(userDataRoot, { recursive: true, force: true }).catch(() => {})
|
|
}
|