Files
2026-07-13 13:12:33 +08:00

541 lines
22 KiB
JavaScript

import { strict as assert } from 'node:assert'
import { spawnSync } from 'node:child_process'
import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } 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 SOURCE_IDENTITY = '# Synthetic imported identity\n'
const TARGET_IDENTITY = '# Synthetic previous Desktop identity\n'
const SOURCE_CHAT = 'synthetic imported chat survives whole-profile transfer'
// A replace import performs two independently bounded receipt-verifier CLI
// calls (60 seconds each) around the mutating import. Windows CI cold starts
// can legitimately approach both bounds, so the E2E timeout must cover the
// product's advertised "few minutes" operation without becoming unbounded.
const PROFILE_IMPORT_APPLY_TIMEOUT_MS = 180_000
async function waitFor(check, label, timeoutMs = 90_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)
}
throw new Error(`Timed out waiting for ${label}: ${lastError?.message || lastError || ''}`)
}
function runPython(source, args) {
const result = spawnSync('uv', ['run', 'python', '-c', source, ...args], {
cwd: repoRoot,
encoding: 'utf8',
env: { ...process.env, UV_CACHE_DIR: join(tmpdir(), 'opensquilla-profile-import-uv-cache') },
})
if (result.status !== 0) {
throw new Error(`Python fixture command failed: ${result.stderr || result.stdout}`)
}
return result.stdout.trim()
}
function seedProfile(home, identity, chat) {
runPython(`
import json, sqlite3, sys
from pathlib import Path
home = Path(sys.argv[1]).resolve()
identity = sys.argv[2]
chat = sys.argv[3]
workspace = home / "workspace"
state = home / "state"
workspace.mkdir(parents=True, exist_ok=True)
state.mkdir(parents=True, exist_ok=True)
for name, value in {
"IDENTITY.md": identity,
"USER.md": "# Synthetic user\\n",
"SOUL.md": "# Synthetic soul\\n",
"MEMORY.md": "# Synthetic memory\\n",
}.items():
(workspace / name).write_text(value, encoding="utf-8", newline="")
(home / "config.toml").write_text(
"workspace_dir = " + json.dumps(str(workspace)) + "\\n"
+ "state_dir = " + json.dumps(str(state)) + "\\n"
+ "[llm]\\nprovider = \\"ollama\\"\\nmodel = \\"synthetic-import-model\\"\\n"
+ "base_url = \\"http://127.0.0.1:11434/v1\\"\\napi_key_env = \\"\\"\\n",
encoding="utf-8",
newline="",
)
with sqlite3.connect(state / "sessions.db") as connection:
connection.execute("CREATE TABLE synthetic_import_chat (id TEXT PRIMARY KEY, body TEXT NOT NULL)")
connection.execute("INSERT INTO synthetic_import_chat VALUES (?, ?)", ("session-1", chat))
assert connection.execute("PRAGMA quick_check").fetchone() == ("ok",)
`, [home, identity, chat])
}
async function writeProviderProfileConfig(home, settings) {
const workspace = join(home, 'workspace')
const state = join(home, 'state')
const lines = [
`workspace_dir = ${JSON.stringify(workspace)}`,
`state_dir = ${JSON.stringify(state)}`,
`search_provider = ${JSON.stringify(settings.searchProvider || 'duckduckgo')}`,
`search_api_key_env = ${JSON.stringify(settings.searchApiKeyEnv || '')}`,
'',
'[llm]',
`provider = ${JSON.stringify(settings.provider)}`,
`model = ${JSON.stringify(settings.model)}`,
`base_url = ${JSON.stringify(settings.baseUrl)}`,
`api_key_env = ${JSON.stringify(settings.apiKeyEnv || '')}`,
'',
'[squilla_router]',
`enabled = ${settings.routerEnabled === true ? 'true' : 'false'}`,
'default_tier = "c2"',
'confidence_threshold = 0.77',
'',
'[squilla_router.tiers.c0]',
`provider = ${JSON.stringify(settings.provider)}`,
'model = "synthetic-source-tier-model"',
'',
'[llm_ensemble]',
'enabled = false',
'selection_mode = "static_openrouter_b5"',
'',
'[privacy]',
`disable_network_observability = ${settings.disableNetworkObservability ? 'true' : 'false'}`,
'',
'[control_ui]',
'enabled = true',
'base_path = "/control"',
'',
]
await writeFile(join(home, 'config.toml'), lines.join('\n'), 'utf8')
}
async function seedDesktopCredential(userData, settings) {
await mkdir(userData, { recursive: true })
const now = '2026-07-12T00:00:00.000Z'
const credential = {
provider: settings.provider,
model: settings.model,
baseUrl: settings.baseUrl,
apiKeyEnv: settings.apiKeyEnv || '',
encryptedApiKey: settings.apiKey
? Buffer.from(settings.apiKey, 'utf8').toString('base64')
: '',
encryption: 'plain',
configAuthority: 'generated',
importTransactionId: '',
createdAt: now,
updatedAt: now,
}
const raw = `${JSON.stringify(credential, null, 2)}\n`
await writeFile(join(userData, 'desktop-credential.json'), raw, { mode: 0o600 })
return raw
}
async function snapshotTree(root) {
const result = {}
async function visit(path, relative = '') {
const info = await lstat(path)
assert.equal(info.isSymbolicLink(), false, `fixture cannot contain symlinks: ${path}`)
if (info.isDirectory()) {
result[`${relative || '.'}/`] = { type: 'directory', mode: info.mode }
for (const name of (await readdir(path)).sort()) {
await visit(join(path, name), relative ? `${relative}/${name}` : name)
}
return
}
assert.equal(info.isFile(), true)
result[relative] = {
type: 'file',
mode: info.mode,
bytes: (await readFile(path)).toString('base64'),
}
}
await visit(root)
return result
}
function launchEnvironment(isolatedHome, port) {
const inherited = { ...process.env }
for (const name of Object.keys(inherited)) {
if (name === 'DISPLAY' || name === 'XAUTHORITY') continue
const upperName = name.toUpperCase()
if (
name.startsWith('OPENSQUILLA_')
|| ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY'].includes(upperName)
|| /(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)/i.test(name)
|| /^(?:AWS|AZURE|GOOGLE|ANTHROPIC|OPENAI|OPENROUTER|MINIMAX|DEEPSEEK|GROQ|MISTRAL|COHERE|GEMINI|OLLAMA|XAI|MOONSHOT|DASHSCOPE|SILICONFLOW|ZHIPU|BAIDU|VOLCENGINE|TENCENT|ALIYUN|HF|HUGGINGFACE)_/i.test(name)
) delete inherited[name]
}
return {
...inherited,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
OPENSQUILLA_DESKTOP_REPO_ROOT: repoRoot,
OPENSQUILLA_DESKTOP_SECRET_STORAGE: 'plain',
OPENSQUILLA_USER_STATE_DIR: join(isolatedHome, 'user-state'),
OPENSQUILLA_TEST_PROFILE_LOCK_ROOT: '1',
OPENSQUILLA_DESKTOP_GATEWAY_PORT: String(port),
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE: '1',
OPENSQUILLA_OPENROUTER_LIVE_PRICING: '0',
UV_CACHE_DIR: join(isolatedHome, '.uv-cache'),
HTTP_PROXY: 'http://127.0.0.1:1',
HTTPS_PROXY: 'http://127.0.0.1:1',
ALL_PROXY: 'http://127.0.0.1:1',
NO_PROXY: '127.0.0.1,localhost',
http_proxy: 'http://127.0.0.1:1',
https_proxy: 'http://127.0.0.1:1',
all_proxy: 'http://127.0.0.1:1',
no_proxy: '127.0.0.1,localhost',
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
}
}
async function launchDesktop(userData, isolatedHome, port) {
return await electron.launch({
args: ['--use-mock-keychain', `--user-data-dir=${userData}`, packageRoot],
env: launchEnvironment(isolatedHome, port),
})
}
async function onboardingPage(app) {
return await waitFor(async () => {
for (const page of app.windows()) {
if (page.isClosed()) continue
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
if (await page.locator('#setup-form').count().catch(() => 0)) return page
}
return null
}, 'Desktop onboarding')
}
async function recoveryPage(app) {
return await waitFor(async () => {
for (const page of app.windows()) {
if (page.isClosed()) continue
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
if (await page.locator('#recoveryPanel.visible').count().catch(() => 0)) return page
}
return null
}, 'recovery profile confirmation page')
}
async function controlPage(app) {
return await waitFor(async () => {
for (const page of app.windows()) {
if (page.isClosed()) continue
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
let pathname = ''
try { pathname = new URL(page.url()).pathname } catch { pathname = '' }
if (!['/control/chat', '/control/chat/new'].includes(pathname)) continue
if (await page.locator('.chat-textarea').count().catch(() => 0)) return page
}
return null
}, 'Desktop Control UI', 120_000)
}
const root = await realpath(await mkdtemp(join(tmpdir(), 'opensquilla-profile-import-e2e-')))
let app = null
try {
// A single detected CLI profile remains unselected, and skipping performs no import.
const skipHome = join(root, 'skip-home')
const skipSource = join(skipHome, '.opensquilla')
const skipDesktopSource = join(root, 'skip-desktop-source')
const skipUserData = join(root, 'skip-user-data')
seedProfile(skipSource, SOURCE_IDENTITY, SOURCE_CHAT)
seedProfile(skipDesktopSource, '# Synthetic alternate Desktop identity\n', 'alternate chat')
const skipSourceBefore = await snapshotTree(skipSource)
const skipDesktopSourceBefore = await snapshotTree(skipDesktopSource)
app = await launchDesktop(skipUserData, skipHome, 18921)
let page = await onboardingPage(app)
await page.locator('[data-screen="5"].active').waitFor({ state: 'visible' })
assert.equal(await page.locator('#migrationSource').inputValue(), '')
assert.equal(await page.locator('#migrationSource option').count(), 2)
assert.equal(await page.locator('#migrationPreview').isDisabled(), true)
await app.evaluate(({ dialog }, selectedPath) => {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [selectedPath] })
}, skipDesktopSource)
await page.locator('#migrationSourceKind').selectOption('desktop-home')
await page.locator('#migrationBrowse').click()
await waitFor(async () => (
await page.locator('#migrationSource option').count() === 3
), 'second explicitly browsed candidate')
assert.equal(await page.locator('#migrationSource').inputValue(), skipDesktopSource)
await page.locator('#migrationSource').selectOption('')
assert.equal(await page.locator('#migrationPreview').isDisabled(), true)
await page.locator('#migrationSkip').click()
await page.locator('[data-screen="0"].active').waitFor({ state: 'visible' })
assert.deepEqual(await snapshotTree(skipSource), skipSourceBefore)
assert.deepEqual(await snapshotTree(skipDesktopSource), skipDesktopSourceBefore)
assert.notEqual(
await readFile(join(skipUserData, 'opensquilla', 'workspace', 'IDENTITY.md'), 'utf8').catch(() => ''),
SOURCE_IDENTITY,
)
await app.close()
app = null
// A non-empty Desktop target is backed up and replaced as one profile; source stays read-only.
const importHome = join(root, 'import-home')
const source = join(importHome, '.opensquilla')
const userData = join(root, 'import-user-data')
const target = join(userData, 'opensquilla')
seedProfile(source, SOURCE_IDENTITY, SOURCE_CHAT)
seedProfile(target, TARGET_IDENTITY, 'synthetic previous Desktop chat')
const sourceBefore = await snapshotTree(source)
app = await launchDesktop(userData, importHome, 18922)
page = await onboardingPage(app)
await page.locator('[data-screen="5"].active').waitFor({ state: 'visible' })
assert.equal(await page.locator('#migrationSource').inputValue(), '')
await page.locator('#migrationSource').selectOption(source)
await waitFor(async () => !(await page.locator('#migrationPreview').isDisabled()), 'explicit source selection')
await page.locator('#migrationPreview').click()
try {
await waitFor(async () => !(await page.locator('#migrationImport').isDisabled()), 'whole-replace preview')
} catch (error) {
const diagnostics = await page.evaluate(() => ({
error: document.getElementById('error')?.textContent || '',
summary: document.getElementById('migrationSummary')?.textContent || '',
source: document.getElementById('migrationSource')?.value || '',
}))
throw new Error(`${error.message}; diagnostics=${JSON.stringify(diagnostics)}`)
}
await app.evaluate(({ dialog }) => {
dialog.showMessageBox = async () => ({ response: 1, checkboxChecked: false })
})
await page.locator('#migrationImport').click()
try {
await page.locator('#migrationDoneNote').waitFor({
state: 'visible',
timeout: PROFILE_IMPORT_APPLY_TIMEOUT_MS,
})
} catch (error) {
const renderer = await page.evaluate(() => ({
error: document.getElementById('error')?.textContent || '',
statusVisible: !document.getElementById('migrationStatus')?.hidden,
summaryVisible: !document.getElementById('migrationSummary')?.hidden,
})).catch(() => ({ error: '', statusVisible: false, summaryVisible: false }))
const pendingPhase = await readFile(
join(userData, 'migration-provider-setup.json'),
'utf8',
).then((raw) => JSON.parse(raw)?.phase || '').catch(() => '')
const receiptCount = await readdir(join(target, 'migration', 'opensquilla'))
.then((entries) => entries.length)
.catch(() => 0)
const backupCount = await readdir(userData)
.then((entries) => entries.filter((name) => name.startsWith('opensquilla.backup.')).length)
.catch(() => 0)
const migrationResult = await readFile(
join(userData, 'migration-last-result.json'),
'utf8',
).then((raw) => {
const value = JSON.parse(raw)
return {
ok: value?.ok === true,
migrationApplied: value?.migrationApplied === true,
restartOk: value?.restartOk === true,
requiresProviderSetup: value?.requiresProviderSetup === true,
detail: typeof value?.detail === 'string' ? value.detail : '',
}
}).catch(() => null)
const diagnostics = {
renderer,
pendingPhase,
receiptCount,
backupCount,
migrationResult,
importedIdentityPresent: await readFile(
join(target, 'workspace', 'IDENTITY.md'),
'utf8',
).then((value) => value === SOURCE_IDENTITY).catch(() => false),
}
const reportDir = process.env.CI_REPORT_DIR
if (reportDir) {
await mkdir(reportDir, { recursive: true })
await writeFile(
join(reportDir, 'profile-import-timeout.json'),
`${JSON.stringify(diagnostics, null, 2)}\n`,
'utf8',
)
}
throw new Error(`${error.message}; diagnostics=${JSON.stringify(diagnostics)}`)
}
assert.match(await page.locator('#migrationDoneNote').innerText(), /Import complete/i)
assert.deepEqual(await snapshotTree(source), sourceBefore, 'source bytes and permissions changed')
assert.equal(await readFile(join(source, '.opensquilla-imported.json'), 'utf8').catch(() => null), null)
assert.equal(await readFile(join(target, 'workspace', 'IDENTITY.md'), 'utf8'), SOURCE_IDENTITY)
const importedChat = runPython(`
import sqlite3, sys
with sqlite3.connect('file:' + sys.argv[1] + '?mode=ro', uri=True) as connection:
print(connection.execute('SELECT body FROM synthetic_import_chat WHERE id = ?', ('session-1',)).fetchone()[0])
`, [join(target, 'state', 'sessions.db')])
assert.equal(importedChat, SOURCE_CHAT)
const backups = (await readdir(userData)).filter((name) => name.startsWith('opensquilla.backup.'))
assert.equal(backups.length, 1)
assert.equal(
await readFile(join(userData, backups[0], 'workspace', 'IDENTITY.md'), 'utf8'),
TARGET_IDENTITY,
)
await app.close()
app = null
// Settings import with a required key must release exclusive admission before
// onboarding, preserve source config bytes, and retain the previous credential.
const settingsHome = join(root, 'settings-home')
const settingsSource = join(settingsHome, '.opensquilla')
const settingsUserData = join(root, 'settings-user-data')
const settingsTarget = join(settingsUserData, 'opensquilla')
seedProfile(settingsSource, SOURCE_IDENTITY, SOURCE_CHAT)
seedProfile(settingsTarget, TARGET_IDENTITY, 'synthetic previous settings chat')
await writeProviderProfileConfig(settingsSource, {
provider: 'openai',
model: 'gpt-5.4-mini',
baseUrl: 'https://api.openai.com/v1',
apiKeyEnv: 'OPENAI_API_KEY',
searchProvider: 'brave',
searchApiKeyEnv: 'BRAVE_API_KEY',
routerEnabled: false,
disableNetworkObservability: true,
})
const importedEnvBytes = Buffer.from(
'OPENAI_API_KEY="synthetic-source-env-key"\r\nTRAILING_VALUE=keep\r\n\r\n',
)
await writeFile(join(settingsSource, '.env'), importedEnvBytes)
await writeProviderProfileConfig(settingsTarget, {
provider: 'openai',
model: 'synthetic-old-target-model',
baseUrl: 'https://api.openai.com/v1',
apiKeyEnv: 'OPENAI_API_KEY',
routerEnabled: true,
disableNetworkObservability: false,
})
const oldCredential = await seedDesktopCredential(settingsUserData, {
provider: 'openai',
model: 'synthetic-old-target-model',
baseUrl: 'https://api.openai.com/v1',
apiKeyEnv: 'OPENAI_API_KEY',
apiKey: 'synthetic-old-target-key',
})
app = await launchDesktop(settingsUserData, settingsHome, 18924)
const settingsControl = await controlPage(app)
const settingsPreview = await settingsControl.evaluate(async (sourcePath) => (
await window.opensquillaDesktop.migrationSummary({ source: sourcePath })
), settingsSource)
assert.equal(settingsPreview.ok, false, JSON.stringify(settingsPreview))
assert.equal(typeof settingsPreview.previewId, 'string')
assert.equal(
settingsPreview.report.items.filter((item) => item.status === 'error').at(0)?.kind,
'preflight/target',
)
await app.evaluate(({ dialog }) => {
dialog.showMessageBox = async () => ({ response: 1, checkboxChecked: false })
})
await settingsControl.evaluate(({ previewId }) => {
void window.opensquillaDesktop.migrationRun({ previewId, overwrite: true })
return true
}, { previewId: settingsPreview.previewId })
const requiredKeyOnboarding = await onboardingPage(app)
await requiredKeyOnboarding.locator('[data-screen="0"].active').waitFor({
state: 'visible',
timeout: 90_000,
})
await requiredKeyOnboarding.locator('[data-screen="0"].active .next-button').click()
await requiredKeyOnboarding.locator('[data-screen="1"].active').waitFor({
state: 'visible',
timeout: 90_000,
})
assert.equal(await requiredKeyOnboarding.locator('#provider').inputValue(), 'openai')
assert.equal(await requiredKeyOnboarding.locator('#model').inputValue(), 'gpt-5.4-mini')
const importedConfigBeforeCredential = await readFile(join(settingsTarget, 'config.toml'))
assert.match(importedConfigBeforeCredential.toString('utf8'), /search_provider = "brave"/)
assert.match(importedConfigBeforeCredential.toString('utf8'), /confidence_threshold = 0\.77/)
assert.match(
importedConfigBeforeCredential.toString('utf8'),
/disable_network_observability = true/,
)
await requiredKeyOnboarding.locator('#apiKey').fill('synthetic-new-imported-key')
await requiredKeyOnboarding.locator('[data-screen="1"].active .next-button').click()
await requiredKeyOnboarding.locator('[data-screen="4"].active').waitFor({ state: 'visible' })
await requiredKeyOnboarding.locator('#finish').click()
const adopted = await waitFor(async () => {
const pending = await readFile(
join(settingsUserData, 'migration-provider-setup.json'),
'utf8',
).catch(() => null)
if (pending !== null) return null
const raw = await readFile(join(settingsUserData, 'desktop-credential.json'), 'utf8')
const credential = JSON.parse(raw)
return credential.configAuthority === 'profile' ? credential : null
}, 'required-key imported credential adoption')
assert.match(adopted.importTransactionId, /^[0-9a-f-]{36}$/i)
assert.equal(adopted.model, 'gpt-5.4-mini')
assert.equal(
Buffer.from(adopted.encryptedApiKey, 'base64').toString('utf8'),
'synthetic-new-imported-key',
)
assert.deepEqual(
await readFile(join(settingsTarget, 'config.toml')),
importedConfigBeforeCredential,
'provider adoption rewrote imported config.toml',
)
assert.deepEqual(
await readFile(join(settingsTarget, '.env')),
importedEnvBytes,
'provider adoption rewrote imported .env bytes',
)
const credentialBackup = join(
settingsUserData,
`desktop-credential.import-backup.${adopted.importTransactionId}.json`,
)
assert.equal(await readFile(credentialBackup, 'utf8'), oldCredential)
if (process.platform !== 'win32') {
assert.equal((await lstat(credentialBackup)).mode & 0o777, 0o600)
}
await app.close()
app = null
// A selected recovery H can use the app, but it cannot import another profile.
const recoveryHome = join(root, 'recovery-home')
const recoveryUserData = join(root, 'recovery-user-data')
const recoveryId = '12345678-1234-4234-8234-123456789abc'
await mkdir(join(recoveryUserData, 'recovery-profiles', recoveryId, 'opensquilla'), { recursive: true })
await writeFile(join(recoveryUserData, 'desktop-profile-context.json'), JSON.stringify({
schema_version: 1,
active_profile_kind: 'recovery',
active_recovery_id: recoveryId,
attention_acknowledgement: null,
updated_at: new Date().toISOString(),
}, null, 2))
app = await launchDesktop(recoveryUserData, recoveryHome, 18923)
page = await recoveryPage(app)
const rejected = await page.evaluate(() => window.opensquillaDesktop.migrationSummary())
assert.equal(rejected.ok, false)
assert.match(rejected.raw, /primary profile/i)
console.log(JSON.stringify({
explicitSelectionAndSkip: true,
multipleCandidates: true,
wholeReplacement: true,
sourceUnchanged: true,
identityAndChatImported: true,
settingsRequiredKeyCompleted: true,
importedConfigPreserved: true,
previousCredentialBackedUp: true,
recoveryProfileRejected: true,
}, null, 2))
} finally {
if (app) await app.close().catch(() => {})
await rm(root, { recursive: true, force: true })
}