chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const packageRoot = resolve(scriptDir, '..')
|
||||
const repoRoot = resolve(packageRoot, '..', '..')
|
||||
const runtimeGatewayDir = join(packageRoot, 'runtime', 'gateway')
|
||||
const pyinstallerWorkDir = join(packageRoot, '.pyinstaller')
|
||||
const entryPath = join(scriptDir, 'gateway-entry.py')
|
||||
const controlUiDistDir = join(repoRoot, 'src', 'opensquilla', 'gateway', 'static', 'dist')
|
||||
const routerBundleDir = join(repoRoot, 'src', 'opensquilla', 'squilla_router', 'models', 'v4.2_phase3_inference')
|
||||
const addDataSeparator = process.platform === 'win32' ? ';' : ':'
|
||||
const gitLfsPointerHeader = 'version https://git-lfs.github.com/spec/v1'
|
||||
|
||||
function findFilesByName(root, fileName) {
|
||||
const matches = []
|
||||
|
||||
function walk(dir) {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(path)
|
||||
} else if (entry.isFile() && entry.name === fileName) {
|
||||
matches.push(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root)
|
||||
return matches
|
||||
}
|
||||
|
||||
function findMacLibomp() {
|
||||
const candidates = []
|
||||
candidates.push(...findFilesByName(runtimeGatewayDir, 'libomp.dylib'))
|
||||
|
||||
const brew = spawnSync('brew', ['--prefix', 'libomp'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
if (brew.status === 0) {
|
||||
const prefix = brew.stdout.trim()
|
||||
if (prefix) candidates.push(join(prefix, 'lib', 'libomp.dylib'))
|
||||
}
|
||||
|
||||
candidates.push(
|
||||
'/opt/homebrew/opt/libomp/lib/libomp.dylib',
|
||||
'/usr/local/opt/libomp/lib/libomp.dylib',
|
||||
'/opt/local/lib/libomp/libomp.dylib',
|
||||
)
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate))
|
||||
}
|
||||
|
||||
function signMacBinary(path) {
|
||||
if (process.platform !== 'darwin') return
|
||||
const result = spawnSync('codesign', ['--force', '--sign', '-', path], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`codesign failed for ${path} with exit ${result.status}:\n${result.stderr || result.stdout}`)
|
||||
}
|
||||
}
|
||||
|
||||
function pythonPackageFile(packageName, relativePath) {
|
||||
const code = [
|
||||
'import importlib.util',
|
||||
'import pathlib',
|
||||
'import sys',
|
||||
`spec = importlib.util.find_spec(${JSON.stringify(packageName)})`,
|
||||
'if spec is None or spec.origin is None:',
|
||||
' sys.exit(1)',
|
||||
`path = pathlib.Path(spec.origin).parent / ${JSON.stringify(relativePath)}`,
|
||||
'if not path.exists():',
|
||||
' sys.exit(2)',
|
||||
'print(path)',
|
||||
].join('\n')
|
||||
const result = spawnSync(
|
||||
'uv',
|
||||
['run', '--extra', 'recommended', 'python', '-c', code],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONUTF8: '1',
|
||||
PYTHONIOENCODING: 'utf-8:replace',
|
||||
},
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
},
|
||||
)
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Could not locate ${packageName}/${relativePath} for the desktop gateway bundle.\n${result.stderr || result.stdout}`,
|
||||
)
|
||||
}
|
||||
|
||||
const path = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1)
|
||||
if (!path || !existsSync(path)) {
|
||||
throw new Error(`Resolved ${packageName}/${relativePath} to an invalid path: ${path || '<empty>'}`)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
function addBinaryArg(sourcePath, destinationDir) {
|
||||
return ['--add-binary', `${sourcePath}${addDataSeparator}${destinationDir}`]
|
||||
}
|
||||
|
||||
function platformLightgbmLibraryPath() {
|
||||
if (process.platform === 'win32') return join('bin', 'lib_lightgbm.dll')
|
||||
if (process.platform === 'darwin') return join('lib', 'lib_lightgbm.dylib')
|
||||
return join('lib', 'lib_lightgbm.so')
|
||||
}
|
||||
|
||||
function platformLightgbmBundleDir() {
|
||||
return process.platform === 'win32' ? 'lightgbm/bin' : 'lightgbm/lib'
|
||||
}
|
||||
|
||||
function readFileHead(path, bytes = 96) {
|
||||
return readFileSync(path).subarray(0, bytes).toString('utf8')
|
||||
}
|
||||
|
||||
function assertRouterAssetsReady() {
|
||||
const manifestPath = join(routerBundleDir, 'artifact_manifest.json')
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(`Router artifact manifest not found at ${manifestPath}.`)
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
const problems = []
|
||||
for (const file of manifest.files || []) {
|
||||
const relPath = String(file.path || '')
|
||||
if (!relPath) continue
|
||||
const path = join(routerBundleDir, relPath)
|
||||
if (!existsSync(path)) {
|
||||
problems.push(`${relPath}: missing`)
|
||||
continue
|
||||
}
|
||||
const actualSize = statSync(path).size
|
||||
if (typeof file.size_bytes === 'number' && actualSize !== file.size_bytes) {
|
||||
problems.push(`${relPath}: size ${actualSize} != manifest ${file.size_bytes}`)
|
||||
continue
|
||||
}
|
||||
if (readFileHead(path).startsWith(gitLfsPointerHeader)) {
|
||||
problems.push(`${relPath}: Git LFS pointer file, not the real router artifact`)
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
'Router V4 Phase 3 assets are incomplete; refusing to build a desktop gateway that degrades routing.',
|
||||
'Run `git lfs pull --include="src/opensquilla/squilla_router/models/v4.2_phase3_inference/**"` and rebuild.',
|
||||
...problems.map((problem) => `- ${problem}`),
|
||||
].join('\n'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function patchMacLightgbmRuntime() {
|
||||
if (process.platform !== 'darwin') return
|
||||
|
||||
const lightgbmLibs = findFilesByName(runtimeGatewayDir, 'lib_lightgbm.dylib')
|
||||
if (lightgbmLibs.length === 0) {
|
||||
throw new Error('LightGBM was requested for the desktop gateway, but lib_lightgbm.dylib was not bundled.')
|
||||
}
|
||||
|
||||
const libomp = findMacLibomp()
|
||||
if (!libomp) {
|
||||
throw new Error(
|
||||
'macOS LightGBM runtime requires libomp.dylib. Ensure scikit-learn is bundled, or install libomp on the build host, for example `brew install libomp`, then rebuild the desktop gateway.',
|
||||
)
|
||||
}
|
||||
|
||||
for (const lightgbmLib of lightgbmLibs) {
|
||||
const bundledLibomp = join(dirname(lightgbmLib), 'libomp.dylib')
|
||||
copyFileSync(libomp, bundledLibomp)
|
||||
signMacBinary(bundledLibomp)
|
||||
|
||||
const result = spawnSync(
|
||||
'install_name_tool',
|
||||
['-change', '@rpath/libomp.dylib', '@loader_path/libomp.dylib', lightgbmLib],
|
||||
{ encoding: 'utf8', windowsHide: true },
|
||||
)
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`install_name_tool failed for ${lightgbmLib} with exit ${result.status}:\n${result.stderr || result.stdout}`,
|
||||
)
|
||||
}
|
||||
signMacBinary(lightgbmLib)
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(join(controlUiDistDir, 'index.html'))) {
|
||||
throw new Error(`Built Control UI not found at ${controlUiDistDir}. Run npm run build:web before npm run build:gateway.`)
|
||||
}
|
||||
assertRouterAssetsReady()
|
||||
|
||||
rmSync(runtimeGatewayDir, { recursive: true, force: true })
|
||||
mkdirSync(runtimeGatewayDir, { recursive: true })
|
||||
mkdirSync(pyinstallerWorkDir, { recursive: true })
|
||||
|
||||
const lightgbmBinaryArgs = addBinaryArg(
|
||||
pythonPackageFile('lightgbm', platformLightgbmLibraryPath()),
|
||||
platformLightgbmBundleDir(),
|
||||
)
|
||||
const macOpenMpBinaryArgs = process.platform === 'darwin'
|
||||
? addBinaryArg(pythonPackageFile('sklearn', join('.dylibs', 'libomp.dylib')), '.')
|
||||
: []
|
||||
|
||||
const args = [
|
||||
'run',
|
||||
'--extra',
|
||||
'recommended',
|
||||
'--extra',
|
||||
'mcp',
|
||||
'--extra',
|
||||
'msg',
|
||||
'--extra',
|
||||
'matrix',
|
||||
'--extra',
|
||||
'document-extras',
|
||||
'--with',
|
||||
'pyinstaller',
|
||||
'pyinstaller',
|
||||
'--noconfirm',
|
||||
'--clean',
|
||||
'--onedir',
|
||||
'--name',
|
||||
'opensquilla-gateway',
|
||||
'--distpath',
|
||||
runtimeGatewayDir,
|
||||
'--workpath',
|
||||
pyinstallerWorkDir,
|
||||
'--specpath',
|
||||
pyinstallerWorkDir,
|
||||
'--collect-all',
|
||||
'opensquilla',
|
||||
'--collect-all',
|
||||
'sqlite_vec',
|
||||
'--collect-binaries',
|
||||
'sklearn',
|
||||
'--copy-metadata',
|
||||
'opensquilla',
|
||||
'--copy-metadata',
|
||||
'scikit-learn',
|
||||
'--copy-metadata',
|
||||
'lightgbm',
|
||||
'--copy-metadata',
|
||||
'yoyo-migrations',
|
||||
'--hidden-import',
|
||||
'joblib',
|
||||
'--hidden-import',
|
||||
'sklearn',
|
||||
'--hidden-import',
|
||||
'sklearn.feature_extraction.text',
|
||||
'--hidden-import',
|
||||
'sklearn.decomposition._truncated_svd',
|
||||
'--hidden-import',
|
||||
'sklearn.decomposition._pca',
|
||||
'--hidden-import',
|
||||
'sklearn.preprocessing._data',
|
||||
'--hidden-import',
|
||||
'lightgbm',
|
||||
'--hidden-import',
|
||||
'tokenizers',
|
||||
'--hidden-import',
|
||||
'tiktoken',
|
||||
'--hidden-import',
|
||||
'onnxruntime',
|
||||
'--hidden-import',
|
||||
'mcp',
|
||||
'--hidden-import',
|
||||
'yoyo.backends.core.sqlite3',
|
||||
'--add-data',
|
||||
`${join(repoRoot, 'migrations')}${addDataSeparator}opensquilla/_migrations`,
|
||||
'--add-data',
|
||||
`${controlUiDistDir}${addDataSeparator}opensquilla/gateway/static/dist`,
|
||||
...lightgbmBinaryArgs,
|
||||
...macOpenMpBinaryArgs,
|
||||
entryPath,
|
||||
]
|
||||
|
||||
const result = spawnSync('uv', args, {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONUTF8: '1',
|
||||
PYTHONIOENCODING: 'utf-8:replace',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
patchMacLightgbmRuntime()
|
||||
@@ -0,0 +1,4 @@
|
||||
from opensquilla.cli.main import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,391 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
import { mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { statSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const packageRoot = resolve(scriptDir, '..')
|
||||
const repoRoot = resolve(packageRoot, '..', '..')
|
||||
const desktopOutputDir = join(repoRoot, 'dist', 'desktop-electron')
|
||||
const sourceRuntimeGatewayDir = join(packageRoot, 'runtime', 'gateway')
|
||||
const binaryName = process.platform === 'win32' ? 'opensquilla-gateway.exe' : 'opensquilla-gateway'
|
||||
const deadlineMs = Number.parseInt(process.env.OPENSQUILLA_GATEWAY_SMOKE_TIMEOUT_MS || '90000', 10)
|
||||
const pollIntervalMs = 250
|
||||
const killGraceMs = 3_000
|
||||
const maxTailLines = 80
|
||||
|
||||
function appendTail(tail, chunk) {
|
||||
const lines = chunk
|
||||
.toString()
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.length > 0)
|
||||
if (lines.length === 0) return tail
|
||||
return [...tail, ...lines].slice(-maxTailLines)
|
||||
}
|
||||
|
||||
function formatTail(stdoutTail, stderrTail) {
|
||||
const parts = []
|
||||
if (stdoutTail.length > 0) parts.push(`stdout tail:\n${stdoutTail.join('\n')}`)
|
||||
if (stderrTail.length > 0) parts.push(`stderr tail:\n${stderrTail.join('\n')}`)
|
||||
return parts.length > 0 ? `\n\n${parts.join('\n\n')}` : ''
|
||||
}
|
||||
|
||||
function pathIsFile(path) {
|
||||
try {
|
||||
return statSync(path).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function pathIsDirectory(path) {
|
||||
try {
|
||||
return statSync(path).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function gatewayBinaryCandidates(runtimeGatewayDir) {
|
||||
return [join(runtimeGatewayDir, 'opensquilla-gateway', binaryName), join(runtimeGatewayDir, binaryName)]
|
||||
}
|
||||
|
||||
function findGatewayBinary(runtimeGatewayDir) {
|
||||
return gatewayBinaryCandidates(runtimeGatewayDir).find(pathIsFile)
|
||||
}
|
||||
|
||||
async function findGeneratedBundleRuntimes(root) {
|
||||
const runtimes = []
|
||||
const seenResourcesDirs = new Set()
|
||||
if (!pathIsDirectory(root)) return runtimes
|
||||
|
||||
function addRuntime(label, resourcesDir, platform) {
|
||||
if (platform !== process.platform || seenResourcesDirs.has(resourcesDir)) return
|
||||
seenResourcesDirs.add(resourcesDir)
|
||||
runtimes.push({
|
||||
label,
|
||||
runtimeGatewayDir: join(resourcesDir, 'runtime', 'gateway'),
|
||||
})
|
||||
}
|
||||
|
||||
async function walk(dir, depth) {
|
||||
if (depth > 5) return
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.name.endsWith('.app')) {
|
||||
addRuntime(`generated app bundle ${path}`, join(path, 'Contents', 'Resources'), 'darwin')
|
||||
} else if (entry.name === 'win-unpacked' || entry.name === 'linux-unpacked') {
|
||||
addRuntime(`generated bundle ${path}`, join(path, 'resources'), entry.name === 'win-unpacked' ? 'win32' : 'linux')
|
||||
} else {
|
||||
await walk(path, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root, 0)
|
||||
return runtimes.sort((left, right) => left.runtimeGatewayDir.localeCompare(right.runtimeGatewayDir))
|
||||
}
|
||||
|
||||
async function selectRuntimeGateway() {
|
||||
const generatedRuntimes = await findGeneratedBundleRuntimes(desktopOutputDir)
|
||||
if (generatedRuntimes.length > 0) {
|
||||
const selected = generatedRuntimes[0]
|
||||
if (generatedRuntimes.length > 1) {
|
||||
console.log(`Found ${generatedRuntimes.length} generated bundle runtimes; selecting first sorted path.`)
|
||||
for (const runtime of generatedRuntimes) console.log(`- ${runtime.runtimeGatewayDir}`)
|
||||
}
|
||||
console.log(`Smoking packaged gateway runtime from ${selected.label}: ${selected.runtimeGatewayDir}`)
|
||||
return selected.runtimeGatewayDir
|
||||
}
|
||||
|
||||
if (process.env.OPENSQUILLA_REQUIRE_PACKAGED_GATEWAY_SMOKE === '1') {
|
||||
throw new Error(`No current-platform generated Electron bundle runtime found under ${desktopOutputDir}.`)
|
||||
}
|
||||
|
||||
console.log(`No current-platform generated Electron bundle runtime found under ${desktopOutputDir}; falling back to source runtime ${sourceRuntimeGatewayDir}.`)
|
||||
return sourceRuntimeGatewayDir
|
||||
}
|
||||
|
||||
function smokeEnv(tempHome, stateDir, config) {
|
||||
const env = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith('OPENSQUILLA_')) continue
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
return {
|
||||
...env,
|
||||
HOME: tempHome,
|
||||
USERPROFILE: tempHome,
|
||||
OPENSQUILLA_DESKTOP: '1',
|
||||
OPENSQUILLA_INSTALL_METHOD: 'desktop',
|
||||
OPENSQUILLA_STATE_DIR: stateDir,
|
||||
OPENSQUILLA_GATEWAY_CONFIG_PATH: config,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONUTF8: '1',
|
||||
PYTHONIOENCODING: 'utf-8:replace',
|
||||
}
|
||||
}
|
||||
|
||||
async function findFreePort() {
|
||||
return await new Promise((resolvePort, reject) => {
|
||||
const server = createServer()
|
||||
server.unref()
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Could not determine an available loopback port.')))
|
||||
return
|
||||
}
|
||||
const { port } = address
|
||||
server.close((error) => {
|
||||
if (error) reject(error)
|
||||
else resolvePort(port)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
await new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
|
||||
}
|
||||
|
||||
function bodySnippet(body) {
|
||||
return body.length > 300 ? `${body.slice(0, 300)}...` : body
|
||||
}
|
||||
|
||||
async function healthCheck(url) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(1_000) })
|
||||
const body = await response.text()
|
||||
if (!response.ok) {
|
||||
return { ok: false, detail: `${url} returned HTTP ${response.status}: ${bodySnippet(body)}` }
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(body)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: `${url} returned HTTP ${response.status} with non-JSON body: ${bodySnippet(body)} (${error instanceof Error ? error.message : String(error)})`,
|
||||
}
|
||||
}
|
||||
|
||||
if (payload?.ok === true) return { ok: true, detail: '' }
|
||||
return { ok: false, detail: `${url} returned JSON without ok=true: ${bodySnippet(body)}` }
|
||||
} catch (error) {
|
||||
return { ok: false, detail: `${url} request failed: ${error instanceof Error ? error.message : String(error)}` }
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) })
|
||||
const body = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} returned HTTP ${response.status}: ${bodySnippet(body)}`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
function controlAssetUrls(html, baseUrl) {
|
||||
const urls = []
|
||||
for (const match of html.matchAll(/<script\b[^>]*\btype="module"[^>]*\bsrc="([^"]+)"/g)) {
|
||||
urls.push(new URL(match[1], baseUrl).toString())
|
||||
}
|
||||
for (const match of html.matchAll(/<link\b[^>]*\brel="stylesheet"[^>]*\bhref="([^"]+)"/g)) {
|
||||
urls.push(new URL(match[1], baseUrl).toString())
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
async function verifyControlUi(port, stdoutTail, stderrTail) {
|
||||
const controlUrl = `http://127.0.0.1:${port}/control/`
|
||||
const html = await fetchText(controlUrl)
|
||||
const assetUrls = controlAssetUrls(html, controlUrl)
|
||||
|
||||
const hasModule = assetUrls.some((url) => url.includes('/static/dist/') && url.endsWith('.js'))
|
||||
const hasStylesheet = assetUrls.some((url) => url.includes('/static/dist/') && url.endsWith('.css'))
|
||||
if (!hasModule || !hasStylesheet) {
|
||||
throw new Error(
|
||||
`${controlUrl} did not inject Vite JS/CSS assets from /static/dist/. ` +
|
||||
`Found assets: ${assetUrls.length > 0 ? assetUrls.join(', ') : '(none)'}.` +
|
||||
formatTail(stdoutTail, stderrTail)
|
||||
)
|
||||
}
|
||||
|
||||
for (const url of assetUrls.filter((assetUrl) => assetUrl.includes('/static/dist/'))) {
|
||||
await fetchText(url)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForGateway(port, childExit, stdoutTail, stderrTail) {
|
||||
const deadline = Date.now() + deadlineMs
|
||||
const healthzUrl = `http://127.0.0.1:${port}/healthz`
|
||||
const healthUrl = `http://127.0.0.1:${port}/health`
|
||||
let lastHealthFailure = ''
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (childExit.value) {
|
||||
const { code, signal } = childExit.value
|
||||
throw new Error(
|
||||
`Gateway exited before becoming healthy (code=${code ?? 'null'} signal=${signal ?? 'null'}).` +
|
||||
formatTail(stdoutTail, stderrTail)
|
||||
)
|
||||
}
|
||||
|
||||
const healthz = await healthCheck(healthzUrl)
|
||||
if (healthz.ok) return
|
||||
const health = await healthCheck(healthUrl)
|
||||
if (health.ok) return
|
||||
lastHealthFailure = `${healthz.detail}; ${health.detail}`
|
||||
await sleep(pollIntervalMs)
|
||||
}
|
||||
|
||||
const detail = lastHealthFailure ? ` Last health failure: ${lastHealthFailure}` : ''
|
||||
throw new Error(`Timed out after ${deadlineMs / 1000}s waiting for ${healthzUrl} or ${healthUrl}.${detail}` + formatTail(stdoutTail, stderrTail))
|
||||
}
|
||||
|
||||
async function terminateChild(child, childClosed) {
|
||||
if (childClosed.value) return
|
||||
|
||||
await new Promise((resolveTerminate) => {
|
||||
let settled = false
|
||||
let forceTimer = null
|
||||
let abandonTimer = null
|
||||
|
||||
function finish() {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (forceTimer) clearTimeout(forceTimer)
|
||||
if (abandonTimer) clearTimeout(abandonTimer)
|
||||
resolveTerminate()
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') child.once('close', finish)
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
const finishAfterTaskkill = () => {
|
||||
if (childClosed.value || child.exitCode !== null || child.signalCode !== null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
abandonTimer = setTimeout(finish, 1_000)
|
||||
}
|
||||
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
})
|
||||
const taskkillTimer = setTimeout(() => {
|
||||
console.warn(`taskkill timed out while terminating gateway process ${child.pid}.`)
|
||||
killer.kill()
|
||||
finishAfterTaskkill()
|
||||
}, killGraceMs)
|
||||
killer.once('error', (error) => {
|
||||
clearTimeout(taskkillTimer)
|
||||
console.warn(`taskkill failed while terminating gateway process ${child.pid}: ${error.message}`)
|
||||
finishAfterTaskkill()
|
||||
})
|
||||
killer.once('close', (code, signal) => {
|
||||
clearTimeout(taskkillTimer)
|
||||
if (code !== 0) {
|
||||
console.warn(`taskkill exited while terminating gateway process ${child.pid} with code=${code ?? 'null'} signal=${signal ?? 'null'}.`)
|
||||
}
|
||||
finishAfterTaskkill()
|
||||
})
|
||||
} else if (process.platform === 'win32') {
|
||||
console.warn('Gateway process had no PID for taskkill fallback.')
|
||||
child.kill()
|
||||
} else {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
|
||||
forceTimer = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
if (process.platform !== 'win32') {
|
||||
child.kill('SIGKILL')
|
||||
abandonTimer = setTimeout(finish, 1_000)
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}, killGraceMs)
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const runtimeGatewayDir = await selectRuntimeGateway()
|
||||
const candidates = gatewayBinaryCandidates(runtimeGatewayDir)
|
||||
const gatewayBinary = findGatewayBinary(runtimeGatewayDir)
|
||||
if (!gatewayBinary) {
|
||||
throw new Error(
|
||||
`Packaged gateway binary is missing. Checked: ${candidates.join(', ')}. Run npm run build:gateway first; release CI should run this after electron-builder.`
|
||||
)
|
||||
}
|
||||
|
||||
const tempHome = await mkdtemp(join(tmpdir(), 'opensquilla-gateway-smoke-'))
|
||||
const config = join(tempHome, 'config.toml')
|
||||
const stateDir = join(tempHome, 'state')
|
||||
let child = null
|
||||
const stdoutTail = []
|
||||
const stderrTail = []
|
||||
const childExit = { value: null }
|
||||
const childClosed = { value: false }
|
||||
|
||||
try {
|
||||
await mkdir(stateDir, { recursive: true })
|
||||
await writeFile(
|
||||
config,
|
||||
[
|
||||
'[auth]',
|
||||
'mode = "none"',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const port = await findFreePort()
|
||||
child = spawn(gatewayBinary, ['gateway', 'run', '--port', String(port), '--bind', '127.0.0.1', '--config', config], {
|
||||
cwd: dirname(gatewayBinary),
|
||||
env: smokeEnv(tempHome, stateDir, config),
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdoutTail.splice(0, stdoutTail.length, ...appendTail(stdoutTail, chunk))
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderrTail.splice(0, stderrTail.length, ...appendTail(stderrTail, chunk))
|
||||
})
|
||||
child.once('close', (code, signal) => {
|
||||
childClosed.value = true
|
||||
childExit.value = { code, signal }
|
||||
})
|
||||
child.once('error', (error) => {
|
||||
childExit.value = { code: null, signal: `spawn error: ${error.message}` }
|
||||
})
|
||||
|
||||
await waitForGateway(port, childExit, stdoutTail, stderrTail)
|
||||
await verifyControlUi(port, stdoutTail, stderrTail)
|
||||
console.log('OpenSquilla packaged gateway smoke passed.')
|
||||
} finally {
|
||||
if (child) await terminateChild(child, childClosed)
|
||||
await rm(tempHome, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { buildCliInvocation } from '../dist/cli-invocation.js'
|
||||
|
||||
// --- bundled posix: env pair + quoted binary, spaces survive quoting ---
|
||||
{
|
||||
const result = buildCliInvocation({
|
||||
platform: 'darwin',
|
||||
mode: 'bundled',
|
||||
binaryPath: '/Applications/OpenSquilla.app/Contents/Resources/runtime/gateway/opensquilla-gateway/opensquilla-gateway',
|
||||
// OPENSQUILLA_STATE_DIR is the OpenSquilla home root; runtime databases
|
||||
// remain under the config-pinned <home>/state directory.
|
||||
stateDir: '/opt/OpenSquilla Data',
|
||||
configPath: '/opt/OpenSquilla Data/config.toml',
|
||||
})
|
||||
assert.equal(result.mode, 'bundled')
|
||||
assert.equal(
|
||||
result.prefix,
|
||||
"OPENSQUILLA_STATE_DIR='/opt/OpenSquilla Data' "
|
||||
+ "OPENSQUILLA_GATEWAY_CONFIG_PATH='/opt/OpenSquilla Data/config.toml' "
|
||||
+ "'/Applications/OpenSquilla.app/Contents/Resources/runtime/gateway/opensquilla-gateway/opensquilla-gateway'",
|
||||
)
|
||||
}
|
||||
|
||||
// --- posix: single quotes inside paths get the '\'' escape ---
|
||||
{
|
||||
const result = buildCliInvocation({
|
||||
platform: 'linux',
|
||||
mode: 'bundled',
|
||||
binaryPath: "/opt/o'brien apps/opensquilla-gateway",
|
||||
stateDir: "/opt/o'brien data",
|
||||
configPath: "/opt/o'brien data/config.toml",
|
||||
})
|
||||
assert.ok(result.prefix.includes("'/opt/o'\\''brien apps/opensquilla-gateway'"))
|
||||
assert.ok(result.prefix.includes("OPENSQUILLA_STATE_DIR='/opt/o'\\''brien data'"))
|
||||
}
|
||||
|
||||
// --- windows: PowerShell $env: syntax, '' doubling, & call operator ---
|
||||
{
|
||||
const result = buildCliInvocation({
|
||||
platform: 'win32',
|
||||
mode: 'bundled',
|
||||
binaryPath: 'C:\\Program Files\\OpenSquilla\\resources\\runtime\\gateway\\opensquilla-gateway.exe',
|
||||
stateDir: "C:\\Users\\o'brien\\AppData\\Roaming\\OpenSquilla\\opensquilla",
|
||||
configPath: 'C:\\Users\\jo\\AppData\\Roaming\\OpenSquilla\\opensquilla\\config.toml',
|
||||
})
|
||||
assert.ok(result.prefix.startsWith("$env:OPENSQUILLA_STATE_DIR = 'C:\\Users\\o''brien\\AppData"))
|
||||
assert.ok(result.prefix.includes("$env:OPENSQUILLA_GATEWAY_CONFIG_PATH = 'C:\\Users\\jo\\AppData"))
|
||||
assert.ok(result.prefix.includes("& 'C:\\Program Files\\OpenSquilla\\resources\\runtime\\gateway\\opensquilla-gateway.exe'"))
|
||||
}
|
||||
|
||||
// --- windows: unicode smart quotes are single-quote delimiters in PowerShell ---
|
||||
{
|
||||
const result = buildCliInvocation({
|
||||
platform: 'win32',
|
||||
mode: 'bundled',
|
||||
binaryPath: 'C:\\Apps\\OpenSquilla\\opensquilla-gateway.exe',
|
||||
stateDir: 'C:\\Users\\O’Brien\\AppData\\Roaming\\OpenSquilla\\opensquilla',
|
||||
configPath: 'C:\\Users\\O’Brien\\AppData\\Roaming\\OpenSquilla\\opensquilla\\config.toml',
|
||||
})
|
||||
assert.ok(result.prefix.includes("$env:OPENSQUILLA_STATE_DIR = 'C:\\Users\\O’’Brien\\AppData"))
|
||||
assert.ok(result.prefix.includes("$env:OPENSQUILLA_GATEWAY_CONFIG_PATH = 'C:\\Users\\O’’Brien\\AppData"))
|
||||
}
|
||||
|
||||
// --- windows dev mode: PowerShell env syntax composes with the uv runner ---
|
||||
{
|
||||
const result = buildCliInvocation({
|
||||
platform: 'win32',
|
||||
mode: 'dev',
|
||||
repoRoot: 'C:\\Dev Projects\\opensquilla',
|
||||
stateDir: 'C:\\Users\\jo\\AppData\\Roaming\\OpenSquilla\\opensquilla',
|
||||
configPath: 'C:\\Users\\jo\\AppData\\Roaming\\OpenSquilla\\opensquilla\\config.toml',
|
||||
})
|
||||
assert.equal(result.mode, 'dev')
|
||||
assert.ok(result.prefix.startsWith("$env:OPENSQUILLA_STATE_DIR = 'C:\\Users\\jo\\AppData"))
|
||||
assert.ok(result.prefix.endsWith("uv run --directory 'C:\\Dev Projects\\opensquilla' opensquilla"))
|
||||
}
|
||||
|
||||
// --- dev mode: uv run with an explicit checkout directory, no cwd dependence ---
|
||||
{
|
||||
const result = buildCliInvocation({
|
||||
platform: 'darwin',
|
||||
mode: 'dev',
|
||||
repoRoot: '/opt/dev projects/opensquilla',
|
||||
stateDir: '/opt/OpenSquilla Data',
|
||||
configPath: '/opt/OpenSquilla Data/config.toml',
|
||||
})
|
||||
assert.equal(result.mode, 'dev')
|
||||
assert.ok(result.prefix.endsWith("uv run --directory '/opt/dev projects/opensquilla' opensquilla"))
|
||||
}
|
||||
|
||||
console.log('cli-invocation: all assertions passed')
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
cleanupSelectorArgs,
|
||||
DesktopCleanupPreviewStore,
|
||||
desktopCleanupScopeIsContained,
|
||||
parseDesktopCleanupMode,
|
||||
parseDesktopCleanupProtocol,
|
||||
sameDesktopCleanupScope,
|
||||
} from '../dist/desktop-cleanup.js'
|
||||
|
||||
const report = parseDesktopCleanupProtocol({
|
||||
schema_version: 1,
|
||||
outcome: 'ready',
|
||||
stable_code: 'cleanup_ready',
|
||||
mode: 'delete-current-profile',
|
||||
items: [{
|
||||
kind: 'primary-home',
|
||||
path: '/synthetic/user-data/opensquilla',
|
||||
exists: true,
|
||||
identity: '1:2',
|
||||
}],
|
||||
transaction_id: 'synthetic-transaction',
|
||||
revision: 42,
|
||||
scope_fingerprint: 'a'.repeat(64),
|
||||
})
|
||||
|
||||
assert.equal(parseDesktopCleanupMode('reset-current-settings'), 'reset-current-settings')
|
||||
assert.equal(parseDesktopCleanupMode('delete-current-profile'), 'delete-current-profile')
|
||||
assert.equal(parseDesktopCleanupMode('delete-all-user-data'), 'delete-all-user-data')
|
||||
assert.equal(parseDesktopCleanupMode('purge'), null)
|
||||
assert.equal(report.items[0].identity, '1:2')
|
||||
assert.equal(desktopCleanupScopeIsContained(report, '/synthetic/user-data'), true)
|
||||
assert.equal(desktopCleanupScopeIsContained({
|
||||
...report,
|
||||
items: [{ ...report.items[0], path: '/synthetic/outside' }],
|
||||
}, '/synthetic/user-data'), false)
|
||||
assert.equal(desktopCleanupScopeIsContained({
|
||||
...report,
|
||||
items: [{ ...report.items[0], path: '/synthetic/user-data/..cache' }],
|
||||
}, '/synthetic/user-data'), true)
|
||||
assert.equal(sameDesktopCleanupScope(report, {
|
||||
...report,
|
||||
revision: 99,
|
||||
items: report.items.map((item) => ({ ...item, identity: '3:4' })),
|
||||
}, '/synthetic/user-data'), true, 'content metadata may change while the gateway stops')
|
||||
assert.equal(sameDesktopCleanupScope(report, {
|
||||
...report,
|
||||
items: [...report.items, {
|
||||
kind: 'new-user-data-entry',
|
||||
path: '/synthetic/user-data/new-entry',
|
||||
exists: true,
|
||||
identity: '5:6',
|
||||
}],
|
||||
}, '/synthetic/user-data'), false, 'new paths require a new visible inventory')
|
||||
|
||||
assert.throws(
|
||||
() => parseDesktopCleanupProtocol({ ...report, schema_version: 2 }),
|
||||
/unsupported protocol schema/,
|
||||
)
|
||||
assert.throws(
|
||||
() => parseDesktopCleanupProtocol({ ...report, revision: Number.MAX_SAFE_INTEGER + 1 }),
|
||||
/invalid revision/,
|
||||
)
|
||||
assert.throws(
|
||||
() => parseDesktopCleanupProtocol({ ...report, scope_fingerprint: 'not-a-digest' }),
|
||||
/invalid scope fingerprint/,
|
||||
)
|
||||
assert.throws(
|
||||
() => parseDesktopCleanupProtocol({ ...report, items: [{ path: '/unbound' }] }),
|
||||
/invalid inventory item/,
|
||||
)
|
||||
|
||||
const primarySelection = {
|
||||
mode: 'delete-current-profile',
|
||||
profileKind: 'primary',
|
||||
recoveryId: null,
|
||||
profileKey: 'primary',
|
||||
}
|
||||
assert.deepEqual(cleanupSelectorArgs(primarySelection), [
|
||||
'--mode', 'delete-current-profile',
|
||||
'--profile-kind', 'primary',
|
||||
])
|
||||
|
||||
const recoverySelection = {
|
||||
mode: 'reset-current-settings',
|
||||
profileKind: 'recovery',
|
||||
recoveryId: '01234567-89ab-4cde-8fab-0123456789ab',
|
||||
profileKey: 'recovery:01234567-89ab-4cde-8fab-0123456789ab',
|
||||
}
|
||||
assert.deepEqual(cleanupSelectorArgs(recoverySelection), [
|
||||
'--mode', 'reset-current-settings',
|
||||
'--profile-kind', 'recovery',
|
||||
'--recovery-id', recoverySelection.recoveryId,
|
||||
])
|
||||
|
||||
const store = new DesktopCleanupPreviewStore(1_000)
|
||||
const preview = store.create(report, primarySelection, 100)
|
||||
assert.equal(
|
||||
store.consume(preview.id, 'recovery:other', 200),
|
||||
null,
|
||||
'a preview must be bound to the exact active profile',
|
||||
)
|
||||
assert.equal(
|
||||
store.consume(preview.id, 'primary', 200),
|
||||
null,
|
||||
'a rejected preview is consumed and cannot be replayed',
|
||||
)
|
||||
|
||||
const fresh = store.create(report, primarySelection, 100)
|
||||
assert.equal(store.consume(fresh.id, 'primary', 1_101), null, 'expired previews fail closed')
|
||||
|
||||
const discardable = store.create(report, primarySelection, 100)
|
||||
assert.equal(store.discard(discardable.id, 'recovery:other'), false)
|
||||
assert.equal(
|
||||
store.consume(discardable.id, 'primary', 200)?.id,
|
||||
discardable.id,
|
||||
'a stale renderer cannot discard another profile preview',
|
||||
)
|
||||
const explicitlyDiscarded = store.create(report, primarySelection, 100)
|
||||
assert.equal(store.discard(explicitlyDiscarded.id, 'primary'), true)
|
||||
assert.equal(
|
||||
store.consume(explicitlyDiscarded.id, 'primary', 200),
|
||||
null,
|
||||
'cancelled previews cannot be replayed',
|
||||
)
|
||||
|
||||
const accepted = store.create(report, primarySelection, 100)
|
||||
assert.equal(
|
||||
store.consume(accepted.id, 'primary', 200)?.report.transaction_id,
|
||||
'synthetic-transaction',
|
||||
)
|
||||
assert.equal(store.consume(accepted.id, 'primary', 201), null, 'previews are one-shot')
|
||||
|
||||
console.log('desktop cleanup contract checks passed')
|
||||
@@ -0,0 +1,274 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
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 RECOVERY_ID = '31234567-89ab-4cde-8fab-0123456789ab'
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await lstat(path)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
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 launchEnvironment(isolatedHome) {
|
||||
const inherited = { ...process.env }
|
||||
for (const name of Object.keys(inherited)) {
|
||||
const upperName = name.toUpperCase()
|
||||
if (
|
||||
upperName.startsWith('OPENSQUILLA_')
|
||||
|| ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY'].includes(upperName)
|
||||
|| /(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)/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: '18895',
|
||||
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE: '1',
|
||||
OPENSQUILLA_OPENROUTER_LIVE_PRICING: '0',
|
||||
OPENSQUILLA_GATEWAY_WORKSPACE_DIR: '',
|
||||
OPENSQUILLA_WORKSPACE_DIR: '',
|
||||
OPENSQUILLA_GATEWAY_STATE_DIR: '',
|
||||
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',
|
||||
}
|
||||
}
|
||||
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'opensquilla-cleanup-e2e-')))
|
||||
const userData = join(root, 'user-data')
|
||||
const isolatedHome = join(root, 'home')
|
||||
const primaryHome = join(userData, 'opensquilla')
|
||||
const workspace = join(primaryHome, 'workspace')
|
||||
const state = join(primaryHome, 'state')
|
||||
const recoveryRoot = join(userData, 'recovery-profiles', RECOVERY_ID)
|
||||
const recoveryHome = join(recoveryRoot, 'opensquilla')
|
||||
const recoveryMarker = join(recoveryHome, 'keep-recovery-profile.txt')
|
||||
const cleanupJournal = join(userData, '.opensquilla.profile-cleanup.json')
|
||||
let desktopApp
|
||||
|
||||
try {
|
||||
await mkdir(workspace, { recursive: true })
|
||||
await mkdir(state, { recursive: true })
|
||||
await mkdir(recoveryHome, { recursive: true })
|
||||
await mkdir(isolatedHome, { recursive: true })
|
||||
await writeFile(join(workspace, 'IDENTITY.md'), 'synthetic cleanup identity\n', 'utf8')
|
||||
await writeFile(join(state, 'sessions.db.keep'), 'synthetic chat state\n', 'utf8')
|
||||
await writeFile(recoveryMarker, 'must remain\n', 'utf8')
|
||||
await writeFile(
|
||||
join(primaryHome, 'config.toml'),
|
||||
`workspace_dir = ${JSON.stringify(join(root, 'missing-workspace'))}\n`,
|
||||
'utf8',
|
||||
)
|
||||
await writeFile(join(userData, 'desktop-locale'), 'en', 'utf8')
|
||||
await writeFile(cleanupJournal, 'synthetic interrupted cleanup authority\n', 'utf8')
|
||||
await writeFile(join(userData, 'desktop-profile-context.json'), `${JSON.stringify({
|
||||
schema_version: 1,
|
||||
active_profile_kind: 'primary',
|
||||
active_recovery_id: null,
|
||||
attention_acknowledgement: null,
|
||||
updated_at: '2026-07-13T00:00:00.000Z',
|
||||
}, null, 2)}\n`, 'utf8')
|
||||
|
||||
desktopApp = await electron.launch({
|
||||
args: ['--use-mock-keychain', `--user-data-dir=${userData}`, packageRoot],
|
||||
env: launchEnvironment(isolatedHome),
|
||||
})
|
||||
const page = await waitFor(async () => {
|
||||
for (const candidate of desktopApp.windows()) {
|
||||
if (candidate.isClosed()) continue
|
||||
if (await candidate.locator('#recoveryPanel.visible').count().catch(() => 0)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, 'cleanup test recovery page')
|
||||
|
||||
// Keep production confirmation intact; cancel the first click and accept the
|
||||
// second so the real recovery-page button proves its busy state is cleared.
|
||||
await desktopApp.evaluate(({ dialog }) => {
|
||||
let cleanupDialogCount = 0
|
||||
dialog.showMessageBox = async () => ({
|
||||
response: cleanupDialogCount++ === 0 ? 0 : 1,
|
||||
checkboxChecked: false,
|
||||
})
|
||||
})
|
||||
|
||||
const identityBeforeAbandon = await readFile(join(workspace, 'IDENTITY.md'))
|
||||
const stateBeforeAbandon = await readFile(join(state, 'sessions.db.keep'))
|
||||
const abandonButton = page.locator('#abandonCleanup')
|
||||
await abandonButton.click()
|
||||
await waitFor(async () => (
|
||||
await abandonButton.isVisible() && await abandonButton.isEnabled()
|
||||
), 'abandon button to recover after native-dialog cancellation')
|
||||
assert.equal(await exists(cleanupJournal), true, 'cancel must preserve the cleanup journal')
|
||||
|
||||
await abandonButton.click()
|
||||
await waitFor(async () => !await exists(cleanupJournal), 'cleanup journal abandonment')
|
||||
await waitFor(async () => (
|
||||
await page.locator('#recoveryCode').textContent() === 'effective_workspace_missing'
|
||||
), 'post-abandon recovery inspection')
|
||||
assert.equal(await page.locator('#cleanupAbandonGroup').isHidden(), true)
|
||||
assert((await readdir(userData)).some((name) => (
|
||||
name.startsWith('.opensquilla.profile-cleanup.abandoned.') && name.endsWith('.json')
|
||||
)))
|
||||
assert.deepEqual(await readFile(join(workspace, 'IDENTITY.md')), identityBeforeAbandon)
|
||||
assert.deepEqual(await readFile(join(state, 'sessions.db.keep')), stateBeforeAbandon)
|
||||
|
||||
const preview = await page.evaluate(() => (
|
||||
window.opensquillaDesktop.inspectDesktopCleanup({ mode: 'delete-current-profile' })
|
||||
))
|
||||
assert.equal(preview.ok, true)
|
||||
assert.equal(preview.report.mode, 'delete-current-profile')
|
||||
assert(preview.report.items.some((item) => (
|
||||
item.kind === 'primary-home' && resolve(item.path) === resolve(primaryHome)
|
||||
)), JSON.stringify(preview.report.items))
|
||||
assert(preview.report.items.some((item) => item.kind === 'primary-logs'))
|
||||
|
||||
const process = desktopApp.process()
|
||||
const exited = new Promise((resolveExit) => process.once('exit', resolveExit))
|
||||
void page.evaluate((previewId) => (
|
||||
window.opensquillaDesktop.applyDesktopCleanup({
|
||||
previewId,
|
||||
acknowledged: true,
|
||||
confirmation: '',
|
||||
})
|
||||
), preview.previewId).catch(() => {})
|
||||
await Promise.race([
|
||||
exited,
|
||||
delay(30_000).then(() => {
|
||||
throw new Error('Desktop did not exit after deleting the current profile.')
|
||||
}),
|
||||
])
|
||||
|
||||
assert.equal(await exists(primaryHome), false)
|
||||
assert.equal(await exists(join(userData, 'desktop-credential.json')), false)
|
||||
assert.equal(await exists(join(userData, 'desktop-profile-context.json')), false)
|
||||
assert.equal(await exists(join(userData, 'logs')), false, 'app.exit must not recreate desktop logs')
|
||||
assert.equal(await readFile(recoveryMarker, 'utf8'), 'must remain\n')
|
||||
|
||||
// Recreate a synthetic primary profile, then verify delete-all is handed to
|
||||
// the detached offline helper and does not begin until this second Electron
|
||||
// process has actually exited.
|
||||
desktopApp = null
|
||||
await mkdir(workspace, { recursive: true })
|
||||
await mkdir(state, { recursive: true })
|
||||
await writeFile(join(workspace, 'IDENTITY.md'), 'synthetic delete-all identity\n', 'utf8')
|
||||
await writeFile(
|
||||
join(primaryHome, 'config.toml'),
|
||||
`workspace_dir = ${JSON.stringify(join(root, 'still-missing-workspace'))}\n`,
|
||||
'utf8',
|
||||
)
|
||||
await writeFile(join(userData, 'desktop-locale'), 'en', 'utf8')
|
||||
await writeFile(join(userData, 'desktop-profile-context.json'), `${JSON.stringify({
|
||||
schema_version: 1,
|
||||
active_profile_kind: 'primary',
|
||||
active_recovery_id: null,
|
||||
attention_acknowledgement: null,
|
||||
updated_at: '2026-07-13T00:00:01.000Z',
|
||||
}, null, 2)}\n`, 'utf8')
|
||||
await delay(200)
|
||||
|
||||
desktopApp = await electron.launch({
|
||||
args: ['--use-mock-keychain', `--user-data-dir=${userData}`, packageRoot],
|
||||
env: launchEnvironment(isolatedHome),
|
||||
})
|
||||
const deleteAllPage = await waitFor(async () => {
|
||||
for (const candidate of desktopApp.windows()) {
|
||||
if (candidate.isClosed()) continue
|
||||
if (await candidate.locator('#recoveryPanel.visible').count().catch(() => 0)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, 'delete-all test recovery page')
|
||||
await desktopApp.evaluate(({ dialog }) => {
|
||||
dialog.showMessageBox = async () => ({ response: 1, checkboxChecked: false })
|
||||
})
|
||||
const deleteAllPreview = await deleteAllPage.evaluate(() => (
|
||||
window.opensquillaDesktop.inspectDesktopCleanup({ mode: 'delete-all-user-data' })
|
||||
))
|
||||
assert.equal(deleteAllPreview.ok, true)
|
||||
assert.equal(await exists(primaryHome), true, 'inspection must remain read-only')
|
||||
assert.equal(await exists(recoveryRoot), true, 'inspection must not touch recovery data')
|
||||
const deleteAllProcess = desktopApp.process()
|
||||
const deleteAllExited = new Promise((resolveExit) => deleteAllProcess.once('exit', resolveExit))
|
||||
void deleteAllPage.evaluate((previewId) => (
|
||||
window.opensquillaDesktop.applyDesktopCleanup({
|
||||
previewId,
|
||||
acknowledged: true,
|
||||
confirmation: 'DELETE ALL OPENSQUILLA DATA',
|
||||
})
|
||||
), deleteAllPreview.previewId).catch(() => {})
|
||||
await Promise.race([
|
||||
deleteAllExited,
|
||||
delay(30_000).then(() => {
|
||||
throw new Error('Desktop did not exit before the delete-all helper handoff.')
|
||||
}),
|
||||
])
|
||||
await waitFor(async () => (
|
||||
!await exists(primaryHome)
|
||||
&& !await exists(recoveryRoot)
|
||||
&& !await exists(join(userData, 'logs'))
|
||||
), 'post-exit delete-all helper completion', 30_000)
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
trustedPreviewVerified: true,
|
||||
postStopReinspectionVerified: true,
|
||||
currentProfileDeleted: true,
|
||||
recoveryProfilePreserved: true,
|
||||
noLogWritebackAfterDelete: true,
|
||||
abandonCleanupVerified: true,
|
||||
deleteAllStartedAfterExit: true,
|
||||
allProfilesDeletedByOfflineHelper: true,
|
||||
}))
|
||||
} catch (error) {
|
||||
const desktopLog = await readFile(join(userData, 'logs', 'desktop.log'), 'utf8').catch(() => '')
|
||||
if (desktopLog) console.error(desktopLog)
|
||||
throw error
|
||||
} finally {
|
||||
await desktopApp?.close().catch(() => {})
|
||||
await rm(root, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { resolveLocaleFromTags } from '../dist/desktop-locale.js'
|
||||
|
||||
// --- preference order: first bundled match wins ---
|
||||
|
||||
// Regression: en-* is bundled and must match in place. Before the fix a Hong
|
||||
// Kong system list [en-HK, zh-Hans-HK, zh-Hant-HK, fr-HK] resolved to 'fr' —
|
||||
// the top two preferences both fell through and the fourth won.
|
||||
assert.equal(resolveLocaleFromTags(['en-HK', 'zh-Hans-HK', 'zh-Hant-HK', 'fr-HK']), 'en')
|
||||
assert.equal(resolveLocaleFromTags(['en', 'ja']), 'en')
|
||||
assert.equal(resolveLocaleFromTags(['en_US']), 'en')
|
||||
assert.equal(resolveLocaleFromTags(['ja-JP', 'en-US']), 'ja')
|
||||
|
||||
// --- Chinese: explicit script subtag wins over region ---
|
||||
|
||||
// Regression: zh-Hans with a Traditional-default region is still Simplified.
|
||||
assert.equal(resolveLocaleFromTags(['zh-Hans-HK']), 'zh-Hans')
|
||||
assert.equal(resolveLocaleFromTags(['zh-Hans-TW']), 'zh-Hans')
|
||||
assert.equal(resolveLocaleFromTags(['zh-Hans-MO']), 'zh-Hans')
|
||||
assert.equal(resolveLocaleFromTags(['zh-CN']), 'zh-Hans')
|
||||
assert.equal(resolveLocaleFromTags(['zh']), 'zh-Hans')
|
||||
assert.equal(resolveLocaleFromTags(['zh-Hans-CN']), 'zh-Hans')
|
||||
assert.equal(resolveLocaleFromTags(['zh_Hans_HK']), 'zh-Hans')
|
||||
|
||||
// Traditional variants (explicit Hant, or bare Traditional-default regions)
|
||||
// skip to the next preference rather than forcing Simplified text.
|
||||
assert.equal(resolveLocaleFromTags(['zh-Hant']), 'en')
|
||||
assert.equal(resolveLocaleFromTags(['zh-Hant-HK', 'ja-JP']), 'ja')
|
||||
assert.equal(resolveLocaleFromTags(['zh-TW']), 'en')
|
||||
assert.equal(resolveLocaleFromTags(['zh-HK', 'fr-FR']), 'fr')
|
||||
assert.equal(resolveLocaleFromTags(['zh-MO', 'de']), 'de')
|
||||
assert.equal(resolveLocaleFromTags(['zh_HK', 'fr-FR']), 'fr')
|
||||
|
||||
// Script detection is structural, never a substring match against extensions
|
||||
// or private-use subtags.
|
||||
assert.equal(resolveLocaleFromTags(['zh-Hant-x-hans', 'ja-JP']), 'ja')
|
||||
assert.equal(resolveLocaleFromTags(['zh-CN-x-hant', 'fr-FR']), 'zh-Hans')
|
||||
|
||||
// --- other bundled languages and fallback ---
|
||||
|
||||
assert.equal(resolveLocaleFromTags(['fr-CA']), 'fr')
|
||||
assert.equal(resolveLocaleFromTags(['fr_CA']), 'fr')
|
||||
assert.equal(resolveLocaleFromTags(['de-AT']), 'de')
|
||||
assert.equal(resolveLocaleFromTags(['es-419']), 'es')
|
||||
// Unsupported languages skip to the next preference; nothing matches → 'en'.
|
||||
assert.equal(resolveLocaleFromTags(['ko-KR', 'es-ES']), 'es')
|
||||
assert.equal(resolveLocaleFromTags(['ko-KR', 'th-TH']), 'en')
|
||||
assert.equal(resolveLocaleFromTags([]), 'en')
|
||||
// Malformed entries (Electron APIs can surface non-strings) are skipped.
|
||||
assert.equal(resolveLocaleFromTags([undefined, null, 42, 'ja']), 'ja')
|
||||
assert.equal(resolveLocaleFromTags(['en--US', 'ja']), 'ja')
|
||||
assert.equal(resolveLocaleFromTags(['zh-Hans-Hant', 'fr']), 'fr')
|
||||
assert.equal(resolveLocaleFromTags(['zha', 'ja']), 'ja')
|
||||
assert.equal(resolveLocaleFromTags(['zh!', 'de']), 'de')
|
||||
|
||||
console.log('desktop-locale resolution tests passed')
|
||||
@@ -0,0 +1,227 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import {
|
||||
allProfileContexts,
|
||||
contextForProfile,
|
||||
desktopProfileContextPath,
|
||||
loadDesktopProfileContext,
|
||||
persistDesktopProfileContextFile,
|
||||
primaryProfilePaths,
|
||||
profileKindEnvironment,
|
||||
serializeDesktopProfileContext,
|
||||
updateDesktopProfileContextFile,
|
||||
} from '../dist/desktop-profile-context.js'
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'opensquilla-profile-context-'))
|
||||
try {
|
||||
const primary = primaryProfilePaths(root)
|
||||
assert.equal(primary.home, join(root, 'opensquilla'))
|
||||
assert.equal(primary.credentialPath, join(root, 'desktop-credential.json'))
|
||||
assert.equal(primary.logsDir, join(root, 'logs'))
|
||||
assert.equal(profileKindEnvironment('primary'), 'desktop-primary')
|
||||
assert.equal(profileKindEnvironment('recovery'), 'desktop-recovery')
|
||||
assert.equal(loadDesktopProfileContext(root).issue, null, 'a genuinely missing context is a fresh primary')
|
||||
|
||||
const recoveryId = '01234567-89ab-4cde-8fab-0123456789ab'
|
||||
const recovery = contextForProfile(root, 'recovery', recoveryId)
|
||||
mkdirSync(recovery.active.home, { recursive: true })
|
||||
await persistDesktopProfileContextFile(root, recovery)
|
||||
if (process.platform !== 'win32') {
|
||||
assert.equal(
|
||||
statSync(desktopProfileContextPath(root)).mode & 0o077,
|
||||
0,
|
||||
'context permissions must not grant group/other access',
|
||||
)
|
||||
}
|
||||
assert.equal(
|
||||
readdirSync(root).some((entry) => entry.includes('desktop-profile-context.json.') && entry.endsWith('.tmp')),
|
||||
false,
|
||||
'durable writes must not leave a temp file behind',
|
||||
)
|
||||
|
||||
const loaded = loadDesktopProfileContext(root)
|
||||
assert.equal(loaded.issue, null)
|
||||
assert.equal(loaded.active.kind, 'recovery')
|
||||
assert.equal(loaded.active.recoveryId, recoveryId)
|
||||
assert.equal(loaded.active.home, join(root, 'recovery-profiles', recoveryId, 'opensquilla'))
|
||||
assert.equal(loaded.active.credentialPath, join(root, 'recovery-profiles', recoveryId, 'desktop-credential.json'))
|
||||
assert.equal(loaded.active.logsDir, join(root, 'recovery-profiles', recoveryId, 'logs'))
|
||||
|
||||
const profiles = allProfileContexts(root)
|
||||
assert.deepEqual(profiles.map((profile) => profile.kind), ['primary', 'recovery'])
|
||||
|
||||
const concurrentRecoveryId = '31234567-89ab-4cde-8fab-0123456789ab'
|
||||
mkdirSync(contextForProfile(root, 'recovery', concurrentRecoveryId).active.home, {
|
||||
recursive: true,
|
||||
})
|
||||
const acknowledgement = {
|
||||
stable_code: 'workspace_conflict',
|
||||
candidates: [{
|
||||
path: join(root, 'opensquilla', 'workspace'),
|
||||
identity: '1:2',
|
||||
modified_at_ns: 123,
|
||||
}],
|
||||
}
|
||||
await Promise.all([
|
||||
updateDesktopProfileContextFile(root, async (current) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
return contextForProfile(
|
||||
root,
|
||||
'recovery',
|
||||
concurrentRecoveryId,
|
||||
new Date().toISOString(),
|
||||
current.persisted.attention_acknowledgement,
|
||||
)
|
||||
}),
|
||||
updateDesktopProfileContextFile(root, (current) => contextForProfile(
|
||||
root,
|
||||
current.active.kind,
|
||||
current.active.recoveryId,
|
||||
new Date().toISOString(),
|
||||
acknowledgement,
|
||||
)),
|
||||
])
|
||||
const concurrentlyUpdated = loadDesktopProfileContext(root)
|
||||
assert.equal(concurrentlyUpdated.active.recoveryId, concurrentRecoveryId)
|
||||
assert.deepEqual(
|
||||
concurrentlyUpdated.persisted.attention_acknowledgement,
|
||||
acknowledgement,
|
||||
'serialized read-modify-write must preserve both concurrent decisions',
|
||||
)
|
||||
|
||||
let releaseLockedUpdate = () => {}
|
||||
let markLockedUpdateStarted = () => {}
|
||||
const lockedUpdateMayFinish = new Promise((resolve) => {
|
||||
releaseLockedUpdate = resolve
|
||||
})
|
||||
const lockedUpdateStarted = new Promise((resolve) => {
|
||||
markLockedUpdateStarted = resolve
|
||||
})
|
||||
const lockedUpdate = updateDesktopProfileContextFile(root, async (current) => {
|
||||
markLockedUpdateStarted()
|
||||
await lockedUpdateMayFinish
|
||||
return contextForProfile(
|
||||
root,
|
||||
current.active.kind,
|
||||
current.active.recoveryId,
|
||||
new Date().toISOString(),
|
||||
current.persisted.attention_acknowledgement,
|
||||
)
|
||||
})
|
||||
await lockedUpdateStarted
|
||||
let directPersistSettled = false
|
||||
const directPersist = persistDesktopProfileContextFile(
|
||||
root,
|
||||
contextForProfile(root, 'primary'),
|
||||
).then(() => {
|
||||
directPersistSettled = true
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
assert.equal(
|
||||
directPersistSettled,
|
||||
false,
|
||||
'direct persistence must join the updater lock instead of publishing mid-transaction',
|
||||
)
|
||||
assert.equal(
|
||||
loadDesktopProfileContext(root).active.recoveryId,
|
||||
concurrentRecoveryId,
|
||||
'a queued direct persistence must leave the updater snapshot untouched',
|
||||
)
|
||||
releaseLockedUpdate()
|
||||
await lockedUpdate
|
||||
await directPersist
|
||||
assert.equal(loadDesktopProfileContext(root).active.kind, 'primary')
|
||||
|
||||
const externalChoice = contextForProfile(root, 'primary')
|
||||
await assert.rejects(
|
||||
updateDesktopProfileContextFile(root, async (current) => {
|
||||
writeFileSync(
|
||||
desktopProfileContextPath(root),
|
||||
serializeDesktopProfileContext(externalChoice),
|
||||
'utf8',
|
||||
)
|
||||
return contextForProfile(
|
||||
root,
|
||||
current.active.kind,
|
||||
current.active.recoveryId,
|
||||
new Date().toISOString(),
|
||||
acknowledgement,
|
||||
)
|
||||
}),
|
||||
/changed while it was being updated/,
|
||||
)
|
||||
assert.equal(
|
||||
loadDesktopProfileContext(root).active.kind,
|
||||
'primary',
|
||||
'CAS rejection must preserve the external writer\'s newer choice',
|
||||
)
|
||||
|
||||
const linkedId = '11234567-89ab-4cde-8fab-0123456789ab'
|
||||
const outside = join(root, 'outside')
|
||||
mkdirSync(outside)
|
||||
const profileCountBeforeLinkedRoot = allProfileContexts(root).length
|
||||
symlinkSync(outside, join(root, 'recovery-profiles', linkedId))
|
||||
assert.equal(
|
||||
allProfileContexts(root).length,
|
||||
profileCountBeforeLinkedRoot,
|
||||
'linked recovery roots must be ignored',
|
||||
)
|
||||
writeFileSync(desktopProfileContextPath(root), serializeDesktopProfileContext(
|
||||
contextForProfile(root, 'recovery', linkedId),
|
||||
), 'utf8')
|
||||
assert.equal(
|
||||
loadDesktopProfileContext(root).issue,
|
||||
'desktop_selected_recovery_profile_unsafe',
|
||||
'a selected linked recovery root must never be activated',
|
||||
)
|
||||
|
||||
const corrupt = '{truncated'
|
||||
writeFileSync(desktopProfileContextPath(root), corrupt, 'utf8')
|
||||
const corruptLoaded = loadDesktopProfileContext(root)
|
||||
assert.equal(corruptLoaded.active.kind, 'primary')
|
||||
assert.equal(corruptLoaded.issue, 'desktop_profile_context_corrupt')
|
||||
assert.equal(readFileSync(desktopProfileContextPath(root), 'utf8'), corrupt, 'inspection preserves corrupt context')
|
||||
|
||||
writeFileSync(desktopProfileContextPath(root), JSON.stringify({
|
||||
schema_version: 2,
|
||||
active_profile_kind: 'primary',
|
||||
active_recovery_id: null,
|
||||
attention_acknowledgement: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
}), 'utf8')
|
||||
assert.equal(
|
||||
loadDesktopProfileContext(root).issue,
|
||||
'desktop_profile_context_schema_too_new',
|
||||
)
|
||||
|
||||
const missingId = '21234567-89ab-4cde-8fab-0123456789ab'
|
||||
writeFileSync(desktopProfileContextPath(root), serializeDesktopProfileContext(
|
||||
contextForProfile(root, 'recovery', missingId),
|
||||
), 'utf8')
|
||||
const missingLoaded = loadDesktopProfileContext(root)
|
||||
assert.equal(missingLoaded.active.kind, 'recovery', 'the vanished selection is not silently changed')
|
||||
assert.equal(missingLoaded.active.recoveryId, missingId)
|
||||
assert.equal(missingLoaded.issue, 'desktop_selected_recovery_profile_missing')
|
||||
|
||||
// An explicit user choice is the only thing that replaces an invalid
|
||||
// context. The durable write then clears the blocked selection state.
|
||||
await persistDesktopProfileContextFile(root, contextForProfile(root, 'primary'))
|
||||
assert.equal(loadDesktopProfileContext(root).issue, null)
|
||||
assert.equal(loadDesktopProfileContext(root).active.kind, 'primary')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log('desktop profile context checks passed')
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { DesktopContextLock } from '../dist/desktop-context-lock.js'
|
||||
import { DesktopWriterAdmission } from '../dist/desktop-writer-admission.js'
|
||||
|
||||
const writers = new DesktopWriterAdmission()
|
||||
const finishExistingWriter = writers.begin('existing recovery writer')
|
||||
const lifecycleOwner = writers.close('apply downloaded update')
|
||||
assert.equal(writers.closed, true)
|
||||
assert.equal(writers.hasOwner(lifecycleOwner), true)
|
||||
assert.throws(
|
||||
() => writers.begin('late context writer'),
|
||||
/writer admission is closed/,
|
||||
)
|
||||
|
||||
let drained = false
|
||||
const drain = writers.waitForAtMost(0).then(() => {
|
||||
drained = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
assert.equal(drained, false, 'lifecycle operation must wait for the active writer')
|
||||
finishExistingWriter()
|
||||
finishExistingWriter()
|
||||
await drain
|
||||
assert.equal(writers.activeCount, 0, 'writer completion must be idempotent')
|
||||
assert.equal(writers.reopen(Symbol('unrelated owner')), false)
|
||||
assert.equal(writers.closed, true, 'an unrelated owner must not reopen admission')
|
||||
assert.equal(writers.reopen(lifecycleOwner), true)
|
||||
assert.equal(writers.closed, false)
|
||||
assert.throws(() => writers.waitForAtMost(-1), /non-negative integer/)
|
||||
|
||||
const exclusive = writers.tryBeginExclusive('recovery selection')
|
||||
assert(exclusive, 'exclusive admission must atomically close and reserve a writer')
|
||||
assert.equal(writers.closed, true)
|
||||
assert.equal(writers.activeCount, 1)
|
||||
assert.equal(writers.tryBeginExclusive('second recovery selection'), null)
|
||||
exclusive.finish()
|
||||
writers.reopen(exclusive.admissionToken)
|
||||
assert.equal(writers.closed, false)
|
||||
assert.equal(writers.activeCount, 0)
|
||||
|
||||
const contextLock = new DesktopContextLock()
|
||||
let releaseFirst = () => {}
|
||||
let markFirstStarted = () => {}
|
||||
const firstMayFinish = new Promise((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
const firstStarted = new Promise((resolve) => {
|
||||
markFirstStarted = resolve
|
||||
})
|
||||
const order = []
|
||||
const first = contextLock.runExclusive('profile-context', async () => {
|
||||
order.push('first-start')
|
||||
markFirstStarted()
|
||||
await firstMayFinish
|
||||
order.push('first-end')
|
||||
})
|
||||
const second = contextLock.runExclusive('profile-context', () => {
|
||||
order.push('second')
|
||||
})
|
||||
await firstStarted
|
||||
assert.deepEqual(order, ['first-start'], 'same-key operations must not overlap')
|
||||
releaseFirst()
|
||||
await Promise.all([first, second])
|
||||
assert.deepEqual(order, ['first-start', 'first-end', 'second'])
|
||||
|
||||
await assert.rejects(
|
||||
contextLock.runExclusive('reentrant', () => contextLock.runExclusive('reentrant', () => {})),
|
||||
/cannot be re-entered/,
|
||||
)
|
||||
await assert.rejects(
|
||||
contextLock.runExclusive('failure-recovery', () => {
|
||||
throw new Error('synthetic failure')
|
||||
}),
|
||||
/synthetic failure/,
|
||||
)
|
||||
await contextLock.runExclusive('failure-recovery', () => {
|
||||
order.push('recovered')
|
||||
})
|
||||
assert.equal(order.at(-1), 'recovered', 'a rejected operation must not poison the queue')
|
||||
|
||||
console.log('desktop profile substrate checks passed')
|
||||
@@ -0,0 +1,190 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { mkdir, mkdtemp, 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 mockVersion = process.env.OPENSQUILLA_DESKTOP_MOCK_UPDATE_VERSION || '99.0.0'
|
||||
const relaunchLabels = ['Relaunch to Update', '重启以更新']
|
||||
|
||||
async function waitFor(check, label, timeoutMs = 45_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 menuLabels(app) {
|
||||
return await app.evaluate(({ Menu }) => {
|
||||
const menu = Menu.getApplicationMenu()
|
||||
const labels = []
|
||||
function walk(items) {
|
||||
for (const item of items || []) {
|
||||
if (item.label) labels.push(item.label)
|
||||
if (item.submenu) walk(item.submenu.items)
|
||||
}
|
||||
}
|
||||
if (menu) walk(menu.items)
|
||||
return labels
|
||||
})
|
||||
}
|
||||
|
||||
async function clickRelaunchToUpdate(app) {
|
||||
return await app.evaluate(({ BrowserWindow, Menu }, labels) => {
|
||||
const menu = Menu.getApplicationMenu()
|
||||
function find(items) {
|
||||
for (const item of items || []) {
|
||||
if (item.label && labels.includes(item.label)) return item
|
||||
const child = item.submenu ? find(item.submenu.items) : null
|
||||
if (child) return child
|
||||
}
|
||||
return null
|
||||
}
|
||||
const item = menu ? find(menu.items) : null
|
||||
if (!item || typeof item.click !== 'function') return false
|
||||
item.click(undefined, BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0], undefined)
|
||||
return true
|
||||
}, relaunchLabels)
|
||||
}
|
||||
|
||||
const isolationRoot = await mkdtemp(join(tmpdir(), 'opensquilla-electron-mock-update-test-'))
|
||||
const userDataDir = join(isolationRoot, 'chromium-user-data')
|
||||
const isolatedHome = join(isolationRoot, 'home')
|
||||
let app
|
||||
|
||||
try {
|
||||
await mkdir(userDataDir, { recursive: true })
|
||||
await mkdir(isolatedHome, { recursive: true })
|
||||
|
||||
// Seed a keyless, entirely synthetic profile so this update test reaches the
|
||||
// Control UI without depending on a developer's real desktop credential.
|
||||
const now = new Date().toISOString()
|
||||
await writeFile(join(userDataDir, 'desktop-credential.json'), JSON.stringify({
|
||||
provider: 'ollama',
|
||||
model: 'opensquilla-update-test-model',
|
||||
baseUrl: 'http://127.0.0.1:11434',
|
||||
apiKeyEnv: '',
|
||||
encryptedApiKey: '',
|
||||
modelRoutingMode: 'direct',
|
||||
routerMode: 'disabled',
|
||||
routerDefaultTier: 'c1',
|
||||
routerTiers: {},
|
||||
searchProvider: 'duckduckgo',
|
||||
searchApiKeyEnv: '',
|
||||
encryptedSearchApiKey: '',
|
||||
encryption: 'plain',
|
||||
disableNetworkObservability: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}, null, 2), { mode: 0o600 })
|
||||
|
||||
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_MOCK_UPDATE_VERSION: mockVersion,
|
||||
// mock install: OK. Availability/download now stay in renderer state.
|
||||
OPENSQUILLA_DESKTOP_MOCK_UPDATE_DIALOG_RESPONSES: '0',
|
||||
},
|
||||
})
|
||||
|
||||
const runtimeIsolation = await app.evaluate(({ app: electronApp }) => ({
|
||||
userData: electronApp.getPath('userData'),
|
||||
home: process.env.HOME,
|
||||
userProfile: process.env.USERPROFILE,
|
||||
}))
|
||||
assert.equal(await realpath(runtimeIsolation.userData), await realpath(userDataDir))
|
||||
assert.equal(resolve(runtimeIsolation.home), resolve(isolatedHome))
|
||||
assert.equal(resolve(runtimeIsolation.userProfile), resolve(isolatedHome))
|
||||
|
||||
const page = await app.firstWindow({ timeout: 60_000 })
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 60_000 }).catch(() => {})
|
||||
await waitFor(
|
||||
async () => page.url().includes('/control/chat'),
|
||||
'Control UI to load on Chat',
|
||||
60_000,
|
||||
)
|
||||
|
||||
const nativeAutoUpdateEnabled = await page.evaluate(
|
||||
() => window.opensquillaDesktop.isAutoUpdateEnabled(),
|
||||
)
|
||||
assert.equal(nativeAutoUpdateEnabled, true, 'mock update should enable native update bridge')
|
||||
|
||||
const updateBannerCount = await page.locator('[data-testid="update-banner"]').count()
|
||||
assert.equal(updateBannerCount, 0, 'desktop native update should suppress the web release banner')
|
||||
|
||||
const availableState = await waitFor(async () => {
|
||||
return await page.evaluate(async () => {
|
||||
const api = window.opensquillaDesktop
|
||||
if (!api.getUpdateState) return null
|
||||
const state = await api.getUpdateState()
|
||||
return state?.status === 'available' ? state : null
|
||||
})
|
||||
}, 'mock update available renderer state')
|
||||
assert.equal(availableState.latestVersion, mockVersion)
|
||||
|
||||
await page.locator('[data-testid="desktop-update-indicator"]').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await page.locator('[data-testid="desktop-update-indicator"]').click()
|
||||
await page.locator('[data-testid="desktop-update-download"]').click()
|
||||
|
||||
const downloadedState = await waitFor(async () => {
|
||||
return await page.evaluate(async () => {
|
||||
const state = await window.opensquillaDesktop.getUpdateState()
|
||||
return state?.status === 'downloaded' ? state : null
|
||||
})
|
||||
}, 'mock update downloaded renderer state')
|
||||
assert.equal(downloadedState.latestVersion, mockVersion)
|
||||
|
||||
const relaunchLabel = await waitFor(async () => {
|
||||
const labels = await menuLabels(app)
|
||||
return labels.find((label) => relaunchLabels.includes(label))
|
||||
}, 'Relaunch to Update menu item')
|
||||
assert.ok(relaunchLabel, 'pending mock update should expose relaunch menu item')
|
||||
|
||||
const clicked = await clickRelaunchToUpdate(app)
|
||||
assert.equal(clicked, true, 'Relaunch to Update menu item should be clickable')
|
||||
|
||||
await delay(500)
|
||||
assert.equal(page.isClosed(), false, 'mock install should not quit the app')
|
||||
assert.match(await page.title(), /OpenSquilla/, 'Control UI should remain available after mock install')
|
||||
|
||||
const labelsAfterClick = await menuLabels(app)
|
||||
assert.ok(
|
||||
labelsAfterClick.some((label) => relaunchLabels.includes(label)),
|
||||
'mock install keeps the pending relaunch menu available for repeated inspection',
|
||||
)
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
version: mockVersion,
|
||||
updateState: downloadedState.status,
|
||||
relaunchLabel,
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
}, null, 2))
|
||||
} finally {
|
||||
await app?.close().catch(() => {})
|
||||
await rm(isolationRoot, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
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(() => {})
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
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 })
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
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 RECOVERY_ID = '21234567-89ab-4cde-8fab-0123456789ab'
|
||||
const observedRendererPages = new WeakSet()
|
||||
const rendererDiagnostics = []
|
||||
|
||||
const LOCALES = {
|
||||
en: {
|
||||
title: 'Starting OpenSquilla',
|
||||
bootAria: 'OpenSquilla startup',
|
||||
recoveryTitle: 'Your primary profile needs recovery',
|
||||
continueRecovery: 'Continue recovery profile',
|
||||
createRecovery: 'Create recovery profile',
|
||||
retryPrimary: 'Retry primary profile',
|
||||
cleanupRecoveryTitle: 'A data cleanup stopped partway through',
|
||||
abandonCleanup: 'Preserve remaining data and continue',
|
||||
},
|
||||
'zh-Hans': {
|
||||
title: '正在启动 OpenSquilla',
|
||||
bootAria: 'OpenSquilla 启动',
|
||||
recoveryTitle: '主配置需要恢复',
|
||||
continueRecovery: '继续恢复配置',
|
||||
createRecovery: '创建恢复配置',
|
||||
retryPrimary: '重试主配置',
|
||||
cleanupRecoveryTitle: '数据清理在中途停止',
|
||||
abandonCleanup: '保留剩余数据并继续',
|
||||
},
|
||||
ja: {
|
||||
title: 'OpenSquilla を起動しています',
|
||||
bootAria: 'OpenSquilla の起動',
|
||||
recoveryTitle: 'プライマリプロファイルの復旧が必要です',
|
||||
continueRecovery: '復旧プロファイルを続行',
|
||||
createRecovery: '復旧プロファイルを作成',
|
||||
retryPrimary: 'プライマリを再試行',
|
||||
cleanupRecoveryTitle: 'データのクリーンアップが途中で停止しました',
|
||||
abandonCleanup: '残りのデータを保持して続行',
|
||||
},
|
||||
fr: {
|
||||
title: "Démarrage d'OpenSquilla",
|
||||
bootAria: "Démarrage d'OpenSquilla",
|
||||
recoveryTitle: 'Le profil principal doit être récupéré',
|
||||
continueRecovery: 'Continuer ce profil',
|
||||
createRecovery: 'Créer un profil de récupération',
|
||||
retryPrimary: 'Réessayer le profil principal',
|
||||
cleanupRecoveryTitle: 'Un nettoyage des données s’est arrêté en cours de route',
|
||||
abandonCleanup: 'Conserver les données restantes et continuer',
|
||||
},
|
||||
de: {
|
||||
title: 'OpenSquilla wird gestartet',
|
||||
bootAria: 'OpenSquilla-Start',
|
||||
recoveryTitle: 'Das Hauptprofil muss wiederhergestellt werden',
|
||||
continueRecovery: 'Profil fortsetzen',
|
||||
createRecovery: 'Wiederherstellungsprofil erstellen',
|
||||
retryPrimary: 'Hauptprofil erneut prüfen',
|
||||
cleanupRecoveryTitle: 'Eine Datenbereinigung wurde unterbrochen',
|
||||
abandonCleanup: 'Verbleibende Daten behalten und fortfahren',
|
||||
},
|
||||
es: {
|
||||
title: 'Iniciando OpenSquilla',
|
||||
bootAria: 'Inicio de OpenSquilla',
|
||||
recoveryTitle: 'El perfil principal necesita recuperación',
|
||||
continueRecovery: 'Continuar perfil de recuperación',
|
||||
createRecovery: 'Crear perfil de recuperación',
|
||||
retryPrimary: 'Reintentar perfil principal',
|
||||
cleanupRecoveryTitle: 'Una limpieza de datos se detuvo a mitad de camino',
|
||||
abandonCleanup: 'Conservar los datos restantes y continuar',
|
||||
},
|
||||
}
|
||||
|
||||
const BLOCKING_CASES = {
|
||||
en: { fixture: 'missing-workspace', stableCode: 'effective_workspace_missing' },
|
||||
'zh-Hans': { fixture: 'corrupt-config', stableCode: 'config_invalid' },
|
||||
ja: { fixture: 'future-config', stableCode: 'config_schema_too_new' },
|
||||
fr: {
|
||||
fixture: 'future-context',
|
||||
stableCode: 'desktop_profile_context_schema_too_new',
|
||||
},
|
||||
de: { fixture: 'unfinished-transaction', stableCode: 'transaction_incomplete' },
|
||||
es: { fixture: 'unsafe-database', stableCode: 'state_database_invalid' },
|
||||
}
|
||||
|
||||
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 || ''}`)
|
||||
}
|
||||
|
||||
async function snapshotTree(root) {
|
||||
const result = {}
|
||||
async function visit(path, relative = '') {
|
||||
const info = await lstat(path)
|
||||
assert.equal(info.isSymbolicLink(), false, `fixture must not contain symlinks: ${path}`)
|
||||
if (info.isDirectory()) {
|
||||
result[`${relative || '.'}/`] = 'directory'
|
||||
for (const name of (await readdir(path)).sort()) {
|
||||
await visit(join(path, name), relative ? `${relative}/${name}` : name)
|
||||
}
|
||||
return
|
||||
}
|
||||
assert.equal(info.isFile(), true, `fixture must contain only files/directories: ${path}`)
|
||||
result[relative] = (await readFile(path)).toString('base64')
|
||||
}
|
||||
await visit(root)
|
||||
return result
|
||||
}
|
||||
|
||||
function launchEnvironment(isolatedHome) {
|
||||
const inherited = { ...process.env }
|
||||
for (const name of Object.keys(inherited)) {
|
||||
if (name === 'DISPLAY' || name === 'XAUTHORITY') continue
|
||||
const upperName = name.toUpperCase()
|
||||
if (
|
||||
upperName.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: '18897',
|
||||
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE: '1',
|
||||
OPENSQUILLA_OPENROUTER_LIVE_PRICING: '0',
|
||||
OPENSQUILLA_GATEWAY_WORKSPACE_DIR: '',
|
||||
OPENSQUILLA_WORKSPACE_DIR: '',
|
||||
OPENSQUILLA_GATEWAY_STATE_DIR: '',
|
||||
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 createFixture(locale, blockingCase) {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), `opensquilla-recovery-a11y-${locale}-`)))
|
||||
const userData = join(root, 'user-data')
|
||||
const isolatedHome = join(root, 'home')
|
||||
const primaryHome = join(userData, 'opensquilla')
|
||||
const primaryWorkspace = join(primaryHome, 'workspace')
|
||||
const primaryState = join(primaryHome, 'state')
|
||||
const missingWorkspace = join(root, 'missing-external-workspace')
|
||||
const recoveryHome = join(userData, 'recovery-profiles', RECOVERY_ID, 'opensquilla')
|
||||
|
||||
await mkdir(primaryWorkspace, { recursive: true })
|
||||
await mkdir(primaryState, { recursive: true })
|
||||
await mkdir(recoveryHome, { recursive: true })
|
||||
await mkdir(isolatedHome, { recursive: true })
|
||||
for (const [name, text] of [
|
||||
['USER.md', 'synthetic accessibility user\n'],
|
||||
['SOUL.md', 'synthetic accessibility soul\n'],
|
||||
['IDENTITY.md', 'synthetic accessibility identity\n'],
|
||||
['MEMORY.md', 'synthetic accessibility memory\n'],
|
||||
]) {
|
||||
await writeFile(join(primaryWorkspace, name), text, 'utf8')
|
||||
}
|
||||
await writeFile(join(primaryState, 'primary-must-not-change.txt'), 'unchanged\n', 'utf8')
|
||||
const validConfig = [
|
||||
`state_dir = ${JSON.stringify(primaryState)}`,
|
||||
'',
|
||||
].join('\n')
|
||||
let config = validConfig
|
||||
if (blockingCase.fixture === 'missing-workspace') {
|
||||
config = [
|
||||
`state_dir = ${JSON.stringify(primaryState)}`,
|
||||
`workspace_dir = ${JSON.stringify(missingWorkspace)}`,
|
||||
'',
|
||||
].join('\n')
|
||||
} else if (blockingCase.fixture === 'corrupt-config') {
|
||||
config = 'workspace_dir = "unterminated\n'
|
||||
} else if (blockingCase.fixture === 'future-config') {
|
||||
config = 'config_version = 999\n'
|
||||
}
|
||||
await writeFile(join(primaryHome, 'config.toml'), config, 'utf8')
|
||||
if (blockingCase.fixture === 'unsafe-database') {
|
||||
await writeFile(join(primaryState, 'sessions.db'), 'not a sqlite database', 'utf8')
|
||||
}
|
||||
let journalPath = null
|
||||
let journalBytes = null
|
||||
if (blockingCase.fixture === 'unfinished-transaction') {
|
||||
journalPath = join(userData, '.opensquilla.profile-replace.json')
|
||||
journalBytes = '{"schema_version":1,"phase":"prepared"}\n'
|
||||
await writeFile(journalPath, journalBytes, 'utf8')
|
||||
} else if (blockingCase.fixture === 'cleanup-transaction') {
|
||||
journalPath = join(userData, '.opensquilla.profile-cleanup.json')
|
||||
journalBytes = 'synthetic interrupted cleanup authority\n'
|
||||
await writeFile(journalPath, journalBytes, 'utf8')
|
||||
}
|
||||
await writeFile(join(userData, 'desktop-locale'), locale, 'utf8')
|
||||
await writeFile(
|
||||
join(userData, 'desktop-profile-context.json'),
|
||||
`${JSON.stringify({
|
||||
schema_version: blockingCase.fixture === 'future-context' ? 999 : 1,
|
||||
active_profile_kind: 'primary',
|
||||
active_recovery_id: null,
|
||||
attention_acknowledgement: null,
|
||||
updated_at: '2026-07-11T00:00:00.000Z',
|
||||
}, null, 2)}\n`,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
return {
|
||||
root,
|
||||
userData,
|
||||
isolatedHome,
|
||||
primaryHome,
|
||||
recoveryHome,
|
||||
journalPath,
|
||||
journalBytes,
|
||||
primaryBefore: await snapshotTree(primaryHome),
|
||||
}
|
||||
}
|
||||
|
||||
async function launchFixture(fixture) {
|
||||
return await electron.launch({
|
||||
args: ['--use-mock-keychain', `--user-data-dir=${fixture.userData}`, packageRoot],
|
||||
env: launchEnvironment(fixture.isolatedHome),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}, 'localized recovery page')
|
||||
}
|
||||
|
||||
function observeRenderer(page) {
|
||||
if (observedRendererPages.has(page)) return
|
||||
observedRendererPages.add(page)
|
||||
page.on('console', (message) => {
|
||||
rendererDiagnostics.push({
|
||||
type: `console:${message.type()}`,
|
||||
text: message.text().slice(0, 1_000),
|
||||
})
|
||||
})
|
||||
page.on('pageerror', (error) => {
|
||||
rendererDiagnostics.push({
|
||||
type: 'pageerror',
|
||||
text: String(error?.message || error).slice(0, 1_000),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function onboardingPage(app) {
|
||||
try {
|
||||
return await waitFor(async () => {
|
||||
for (const page of app.windows()) {
|
||||
if (page.isClosed()) continue
|
||||
observeRenderer(page)
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
|
||||
if (await page.locator('#setup-form').count().catch(() => 0)) return page
|
||||
}
|
||||
return null
|
||||
}, 'selected recovery profile onboarding')
|
||||
} catch (error) {
|
||||
const windows = await Promise.all(app.windows().map(async (page) => ({
|
||||
url: page.url(),
|
||||
title: await page.title().catch(() => ''),
|
||||
body: await page.locator('body').innerText().catch(() => '').then((value) => (
|
||||
value.slice(0, 1_500)
|
||||
)),
|
||||
})))
|
||||
throw new Error(
|
||||
`${error.message}; windows=${JSON.stringify(windows)}; `
|
||||
+ `renderer=${JSON.stringify(rendererDiagnostics.slice(-30))}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function tabTo(page, targetId, maximumTabs = 30) {
|
||||
for (let index = 0; index <= maximumTabs; index += 1) {
|
||||
const activeId = await page.evaluate(() => document.activeElement?.id || '')
|
||||
if (activeId === targetId) return
|
||||
await page.keyboard.press('Tab')
|
||||
}
|
||||
throw new Error(`Keyboard focus did not reach #${targetId}`)
|
||||
}
|
||||
|
||||
function durationSeconds(value) {
|
||||
if (value.endsWith('ms')) return Number.parseFloat(value) / 1_000
|
||||
if (value.endsWith('s')) return Number.parseFloat(value)
|
||||
return Number.NaN
|
||||
}
|
||||
|
||||
async function assertReducedMotion(page) {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
const result = await page.evaluate(() => {
|
||||
const bodyWasErrored = document.body.classList.contains('errored')
|
||||
const blockedStyle = getComputedStyle(
|
||||
document.querySelector('.status-line'),
|
||||
'::before',
|
||||
)
|
||||
const blockedAnimationName = blockedStyle.animationName
|
||||
document.body.classList.remove('errored')
|
||||
const reducedStyle = getComputedStyle(
|
||||
document.querySelector('.status-line'),
|
||||
'::before',
|
||||
)
|
||||
const snapshot = {
|
||||
mediaMatches: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
blockedAnimationName,
|
||||
animationName: reducedStyle.animationName,
|
||||
animationDuration: reducedStyle.animationDuration,
|
||||
animationIterationCount: reducedStyle.animationIterationCount,
|
||||
scrollBehavior: getComputedStyle(document.documentElement).scrollBehavior,
|
||||
}
|
||||
if (bodyWasErrored) document.body.classList.add('errored')
|
||||
return snapshot
|
||||
})
|
||||
assert.equal(result.mediaMatches, true)
|
||||
assert.equal(result.blockedAnimationName, 'none')
|
||||
assert.equal(result.animationName, 'progress')
|
||||
assert(durationSeconds(result.animationDuration) <= 0.00001, result.animationDuration)
|
||||
assert.equal(result.animationIterationCount, '1')
|
||||
assert.equal(result.scrollBehavior, 'auto')
|
||||
}
|
||||
|
||||
async function assertLocalizedRecovery(page, locale, expected) {
|
||||
await waitFor(
|
||||
async () => await page.locator('html').getAttribute('lang') === locale,
|
||||
`${locale} locale application`,
|
||||
)
|
||||
assert.equal(await page.title(), expected.title)
|
||||
assert.equal(await page.locator('main.boot').getAttribute('aria-label'), expected.bootAria)
|
||||
assert.equal(await page.locator('#recoveryTitle').innerText(), expected.recoveryTitle)
|
||||
assert.equal(await page.locator('#continueRecovery').innerText(), expected.continueRecovery)
|
||||
assert.equal(await page.locator('#createRecovery').innerText(), expected.createRecovery)
|
||||
assert.equal(await page.locator('#retryPrimary').innerText(), expected.retryPrimary)
|
||||
assert.equal(await page.locator('#recoveryPanel').getAttribute('role'), 'region')
|
||||
assert.equal(await page.locator('#recoveryPanel').getAttribute('aria-labelledby'), 'recoveryTitle')
|
||||
assert.equal(
|
||||
await page.getByRole('region', { name: expected.recoveryTitle }).count(),
|
||||
1,
|
||||
)
|
||||
assert.equal(await page.locator('#recoveryStatus').getAttribute('role'), 'status')
|
||||
assert.equal(await page.locator('#recoveryStatus').getAttribute('aria-live'), 'polite')
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.id), 'recoveryTitle')
|
||||
}
|
||||
|
||||
const completedLocales = []
|
||||
const completedBlockingCodes = []
|
||||
const completedCleanupLocales = []
|
||||
for (const [locale, expected] of Object.entries(LOCALES)) {
|
||||
const blockingCase = BLOCKING_CASES[locale]
|
||||
assert(blockingCase, `missing blocking fixture for ${locale}`)
|
||||
const fixture = await createFixture(locale, blockingCase)
|
||||
let app
|
||||
try {
|
||||
app = await launchFixture(fixture)
|
||||
const page = await recoveryPage(app)
|
||||
assert.deepEqual(
|
||||
await readdir(fixture.recoveryHome),
|
||||
[],
|
||||
'read-only recovery inspection must not seed a third blank workspace',
|
||||
)
|
||||
assert.equal(await page.locator('#recoveryCode').innerText(), blockingCase.stableCode)
|
||||
await assertLocalizedRecovery(page, locale, expected)
|
||||
|
||||
const originalRecoveryState = await page.evaluate(() => (
|
||||
window.opensquillaDesktop.getRecoveryState()
|
||||
))
|
||||
await page.evaluate((state) => {
|
||||
window.renderRecoveryState({
|
||||
...state,
|
||||
blocked: true,
|
||||
inspection: {
|
||||
...state.inspection,
|
||||
outcome: 'recovery_required',
|
||||
stable_code: 'cleanup_transaction_incomplete',
|
||||
allowed_actions: [
|
||||
'abandon-cleanup',
|
||||
'continue-recovery-profile',
|
||||
'create-recovery-profile',
|
||||
'retry-primary-profile',
|
||||
'show-backups',
|
||||
'copy-diagnostics',
|
||||
],
|
||||
},
|
||||
}, false)
|
||||
}, originalRecoveryState)
|
||||
assert.equal(await page.locator('#recoveryTitle').innerText(), expected.cleanupRecoveryTitle)
|
||||
assert.equal(await page.locator('#abandonCleanup').innerText(), expected.abandonCleanup)
|
||||
assert.equal(await page.locator('#cleanupAbandonGroup').getAttribute('hidden'), null)
|
||||
completedCleanupLocales.push(locale)
|
||||
await page.evaluate((state) => window.renderRecoveryState(state, true), originalRecoveryState)
|
||||
|
||||
if (locale === 'en') await assertReducedMotion(page)
|
||||
|
||||
const existingProfiles = await page.locator('#recoveryProfiles option').evaluateAll((options) => (
|
||||
options.map((option) => ({ value: option.value, label: option.textContent || '' }))
|
||||
))
|
||||
assert(existingProfiles.some((option) => (
|
||||
option.value === RECOVERY_ID && option.label.includes(fixture.recoveryHome)
|
||||
)))
|
||||
const gatewayBeforeChoice = await page.evaluate(() => (
|
||||
window.opensquillaDesktop.getGatewayStatus()
|
||||
))
|
||||
assert.equal(gatewayBeforeChoice.status, 'stopped')
|
||||
assert.equal(gatewayBeforeChoice.owned, false)
|
||||
|
||||
// The entire fallback selection is keyboard-only. Starting from the
|
||||
// programmatically focused recovery heading, Tab reaches the existing
|
||||
// profile selector, then Enter activates Continue without a pointer click.
|
||||
await tabTo(page, 'recoveryProfiles')
|
||||
assert.equal(await page.locator('#recoveryProfiles').inputValue(), RECOVERY_ID)
|
||||
await page.keyboard.press('Home')
|
||||
assert.equal(await page.locator('#recoveryProfiles').inputValue(), RECOVERY_ID)
|
||||
await page.keyboard.press('Tab')
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.id), 'continueRecovery')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const onboarding = await onboardingPage(app)
|
||||
const gatewayAfterChoice = await onboarding.evaluate(() => (
|
||||
window.opensquillaDesktop.getGatewayStatus()
|
||||
))
|
||||
assert.equal(gatewayAfterChoice.status, 'stopped')
|
||||
assert.equal(gatewayAfterChoice.owned, false)
|
||||
const context = JSON.parse(
|
||||
await readFile(join(fixture.userData, 'desktop-profile-context.json'), 'utf8'),
|
||||
)
|
||||
assert.equal(context.active_profile_kind, 'recovery')
|
||||
assert.equal(context.active_recovery_id, RECOVERY_ID)
|
||||
assert.deepEqual(await snapshotTree(fixture.primaryHome), fixture.primaryBefore)
|
||||
if (fixture.journalPath) {
|
||||
assert.equal(await readFile(fixture.journalPath, 'utf8'), fixture.journalBytes)
|
||||
}
|
||||
completedLocales.push(locale)
|
||||
completedBlockingCodes.push(blockingCase.stableCode)
|
||||
} finally {
|
||||
await app?.close().catch(() => {})
|
||||
await rm(fixture.root, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(completedLocales, Object.keys(LOCALES))
|
||||
assert.deepEqual(completedCleanupLocales, Object.keys(LOCALES))
|
||||
assert.deepEqual(
|
||||
completedBlockingCodes,
|
||||
Object.values(BLOCKING_CASES).map((item) => item.stableCode),
|
||||
)
|
||||
|
||||
// A real compiled Electron launch also covers the cleanup-specific recovery
|
||||
// state. The destructive action itself is not activated here (the native
|
||||
// confirmation is intentionally outside renderer automation); keyboard focus,
|
||||
// localized explanation, ARIA region, and the untouched primary bytes are.
|
||||
const cleanupFixture = await createFixture('en', {
|
||||
fixture: 'cleanup-transaction',
|
||||
stableCode: 'cleanup_transaction_incomplete',
|
||||
})
|
||||
let cleanupApp
|
||||
try {
|
||||
cleanupApp = await launchFixture(cleanupFixture)
|
||||
const cleanupPage = await recoveryPage(cleanupApp)
|
||||
assert.equal(await cleanupPage.locator('#recoveryCode').innerText(), 'cleanup_transaction_incomplete')
|
||||
assert.equal(await cleanupPage.locator('#recoveryTitle').innerText(), LOCALES.en.cleanupRecoveryTitle)
|
||||
assert.equal(await cleanupPage.locator('#cleanupAbandonGroup').getAttribute('hidden'), null)
|
||||
assert.equal(await cleanupPage.locator('#abandonCleanup').innerText(), LOCALES.en.abandonCleanup)
|
||||
assert.equal(await cleanupPage.locator('#recoveryPanel').getAttribute('role'), 'region')
|
||||
assert.equal(await cleanupPage.locator('#recoveryStatus').getAttribute('aria-live'), 'polite')
|
||||
await tabTo(cleanupPage, 'abandonCleanup')
|
||||
assert.equal(await cleanupPage.evaluate(() => document.activeElement?.id), 'abandonCleanup')
|
||||
assert.deepEqual(await snapshotTree(cleanupFixture.primaryHome), cleanupFixture.primaryBefore)
|
||||
assert.equal(
|
||||
await readFile(cleanupFixture.journalPath, 'utf8'),
|
||||
cleanupFixture.journalBytes,
|
||||
)
|
||||
} finally {
|
||||
await cleanupApp?.close().catch(() => {})
|
||||
await rm(cleanupFixture.root, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
locales: completedLocales,
|
||||
blockingCodes: completedBlockingCodes,
|
||||
keyboardContinueVerified: true,
|
||||
primaryBytesUnchanged: true,
|
||||
reducedMotionVerified: true,
|
||||
cleanupAbandonKeyboardVerified: true,
|
||||
}))
|
||||
@@ -0,0 +1,667 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createServer } from 'node:http'
|
||||
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 screenshotPath = String(process.env.OPENSQUILLA_DESKTOP_RECOVERY_SCREENSHOT || '').trim()
|
||||
const PRIMARY_SENTINEL = 'synthetic-primary-credential-must-not-be-copied'
|
||||
const RECOVERY_SYNTHETIC_KEY = 'synthetic-loopback-only-recovery-key'
|
||||
const FIRST_PROMPT = 'RECOVERY_E2E_FIRST_PROMPT'
|
||||
const SECOND_PROMPT = 'RECOVERY_E2E_SECOND_PROMPT'
|
||||
const REPLY = 'RECOVERY_E2E_REPLY'
|
||||
const observedRendererPages = new WeakSet()
|
||||
const rendererDiagnostics = []
|
||||
|
||||
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',
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Python fixture command failed: ${result.stderr || result.stdout}`)
|
||||
}
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
async function snapshotTree(root) {
|
||||
const result = {}
|
||||
async function visit(path, relative = '') {
|
||||
const info = await lstat(path)
|
||||
assert.equal(info.isSymbolicLink(), false, `fixture must not contain symlinks: ${path}`)
|
||||
if (info.isDirectory()) {
|
||||
result[`${relative || '.'}/`] = 'directory'
|
||||
for (const name of (await readdir(path)).sort()) {
|
||||
await visit(join(path, name), relative ? `${relative}/${name}` : name)
|
||||
}
|
||||
return
|
||||
}
|
||||
assert.equal(info.isFile(), true, `fixture must contain only files/directories: ${path}`)
|
||||
result[relative] = (await readFile(path)).toString('base64')
|
||||
}
|
||||
await visit(root)
|
||||
return result
|
||||
}
|
||||
|
||||
async function snapshotPrimary(profile, credentialPath) {
|
||||
return {
|
||||
profile: await snapshotTree(profile),
|
||||
credential: (await readFile(credentialPath)).toString('base64'),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}, 'unsafe-primary recovery page')
|
||||
}
|
||||
|
||||
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
|
||||
}, 'recovery-profile onboarding window')
|
||||
}
|
||||
|
||||
async function controlPage(app) {
|
||||
const observe = (page) => {
|
||||
if (observedRendererPages.has(page)) return
|
||||
observedRendererPages.add(page)
|
||||
page.on('console', (message) => {
|
||||
rendererDiagnostics.push({ type: `console:${message.type()}`, text: message.text().slice(0, 1_000) })
|
||||
})
|
||||
page.on('pageerror', (error) => {
|
||||
rendererDiagnostics.push({ type: 'pageerror', text: String(error?.message || error).slice(0, 1_000) })
|
||||
})
|
||||
}
|
||||
try {
|
||||
const page = await waitFor(async () => {
|
||||
for (const candidate of app.windows()) {
|
||||
if (candidate.isClosed()) continue
|
||||
observe(candidate)
|
||||
await candidate.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
|
||||
let pathname = ''
|
||||
try { pathname = new URL(candidate.url()).pathname } catch { pathname = '' }
|
||||
if (pathname !== '/control/chat' && pathname !== '/control/chat/new') continue
|
||||
if (await candidate.locator('.chat-textarea').count().catch(() => 0)) return candidate
|
||||
}
|
||||
return null
|
||||
}, 'recovery-profile Control UI', 120_000)
|
||||
await waitFor(() => {
|
||||
try { return new URL(page.url()).pathname === '/control/chat/new' } catch { return false }
|
||||
}, 'new-chat draft route', 30_000)
|
||||
return page
|
||||
} catch (error) {
|
||||
const windows = await Promise.all(app.windows().map(async (page) => ({
|
||||
url: page.url(),
|
||||
title: await page.title().catch(() => ''),
|
||||
body: await page.locator('body').innerText().catch(() => '').then((value) => value.slice(0, 1_500)),
|
||||
})))
|
||||
throw new Error(
|
||||
`${error.message}; windows=${JSON.stringify(windows)}; renderer=${JSON.stringify(rendererDiagnostics.slice(-30))}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendChat(page, prompt) {
|
||||
const textarea = page.locator('.chat-textarea')
|
||||
await textarea.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
try {
|
||||
await waitFor(async () => {
|
||||
// The Control UI can finish one last reactive render after its textarea
|
||||
// first becomes visible. Refill before sending if that render replaced
|
||||
// the input; never press Enter until the exact synthetic prompt remains.
|
||||
if (await textarea.inputValue().catch(() => '') !== prompt) {
|
||||
await textarea.fill(prompt)
|
||||
}
|
||||
return await page.locator('.chat-send-btn.is-ready').count().catch(() => 0)
|
||||
}, 'ready recovery chat composer', 10_000)
|
||||
} catch (error) {
|
||||
const state = await page.evaluate(() => ({
|
||||
href: window.location.href,
|
||||
sessionKey: document.querySelector('.chat-label')?.getAttribute('title') || '',
|
||||
textareaValue: document.querySelector('.chat-textarea')?.value || '',
|
||||
sendButtonClass: document.querySelector('.chat-send-btn')?.className || '',
|
||||
bodyText: document.body.innerText.slice(0, 1_000),
|
||||
})).catch(() => ({ unavailable: true }))
|
||||
throw new Error(`${error.message}; composer=${JSON.stringify(state)}`)
|
||||
}
|
||||
await textarea.press('Enter')
|
||||
await page.locator('.msg-ai').filter({ hasText: REPLY }).last().waitFor({
|
||||
state: 'visible',
|
||||
timeout: 60_000,
|
||||
})
|
||||
await waitFor(async () => (
|
||||
await page.locator('.chat-thread').getAttribute('aria-busy') === 'false'
|
||||
), 'completed recovery chat turn', 60_000)
|
||||
}
|
||||
|
||||
function launchEnvironment(isolatedHome, providerPort, sourceEnvironment = process.env) {
|
||||
const inherited = { ...sourceEnvironment }
|
||||
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: '18898',
|
||||
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE: '1',
|
||||
OPENSQUILLA_OPENROUTER_LIVE_PRICING: '0',
|
||||
OPENSQUILLA_GATEWAY_WORKSPACE_DIR: '',
|
||||
OPENSQUILLA_WORKSPACE_DIR: '',
|
||||
OPENSQUILLA_GATEWAY_STATE_DIR: '',
|
||||
OPENSQUILLA_E2E_PROVIDER_PORT: String(providerPort),
|
||||
PYTHONFAULTHANDLER: '1',
|
||||
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, providerPort) {
|
||||
return await electron.launch({
|
||||
args: ['--use-mock-keychain', `--user-data-dir=${userData}`, packageRoot],
|
||||
env: launchEnvironment(isolatedHome, providerPort),
|
||||
})
|
||||
}
|
||||
|
||||
async function startFakeProvider() {
|
||||
const requests = []
|
||||
const server = createServer(async (request, response) => {
|
||||
const chunks = []
|
||||
for await (const chunk of request) chunks.push(chunk)
|
||||
const raw = Buffer.concat(chunks).toString('utf8')
|
||||
let payload = {}
|
||||
try { payload = raw ? JSON.parse(raw) : {} } catch { payload = {} }
|
||||
requests.push({ method: request.method, url: request.url, payload })
|
||||
|
||||
if (request.method === 'GET' && request.url?.endsWith('/models')) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ object: 'list', data: [{ id: 'synthetic-recovery-model' }] }))
|
||||
return
|
||||
}
|
||||
if (request.method === 'GET' && request.url?.endsWith('/api/tags')) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ models: [{ name: 'synthetic-recovery-model' }] }))
|
||||
return
|
||||
}
|
||||
if (request.method !== 'POST' || !request.url?.endsWith('/chat/completions')) {
|
||||
response.writeHead(404, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: 'synthetic endpoint not found' } }))
|
||||
return
|
||||
}
|
||||
if (payload.stream === false) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
id: 'chatcmpl-recovery-title',
|
||||
object: 'chat.completion',
|
||||
model: 'synthetic-recovery-model',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'Recovery chat' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 },
|
||||
}))
|
||||
return
|
||||
}
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'close',
|
||||
})
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: 'chatcmpl-recovery-e2e',
|
||||
object: 'chat.completion.chunk',
|
||||
model: 'synthetic-recovery-model',
|
||||
choices: [{ index: 0, delta: { role: 'assistant', content: REPLY }, finish_reason: null }],
|
||||
})}\n\n`)
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: 'chatcmpl-recovery-e2e',
|
||||
object: 'chat.completion.chunk',
|
||||
model: 'synthetic-recovery-model',
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 12, completion_tokens: 3, total_tokens: 15 },
|
||||
})}\n\n`)
|
||||
response.end('data: [DONE]\n\n')
|
||||
})
|
||||
await new Promise((resolveListen, rejectListen) => {
|
||||
server.once('error', rejectListen)
|
||||
server.listen(0, '127.0.0.1', resolveListen)
|
||||
})
|
||||
const address = server.address()
|
||||
assert(address && typeof address === 'object')
|
||||
return {
|
||||
port: address.port,
|
||||
requests,
|
||||
close: () => new Promise((resolveClose, rejectClose) => {
|
||||
server.close((error) => error ? rejectClose(error) : resolveClose())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'opensquilla-electron-recovery-test-')))
|
||||
const userData = join(root, 'user-data')
|
||||
const isolatedHome = join(root, 'home')
|
||||
const primaryHome = join(userData, 'opensquilla')
|
||||
const primaryWorkspace = join(primaryHome, 'workspace')
|
||||
const primaryState = join(primaryHome, 'state')
|
||||
const primaryDatabase = join(primaryState, 'sessions.db')
|
||||
const primaryCredential = join(userData, 'desktop-credential.json')
|
||||
const missingWorkspace = join(root, 'missing-external-workspace')
|
||||
|
||||
await mkdir(primaryWorkspace, { recursive: true })
|
||||
await mkdir(primaryState, { recursive: true })
|
||||
await mkdir(isolatedHome, { recursive: true })
|
||||
for (const [name, text] of [
|
||||
['USER.md', 'synthetic primary user\n'],
|
||||
['SOUL.md', 'synthetic primary soul\n'],
|
||||
['IDENTITY.md', 'synthetic primary identity\n'],
|
||||
['MEMORY.md', 'synthetic primary memory\n'],
|
||||
]) {
|
||||
await writeFile(join(primaryWorkspace, name), text, 'utf8')
|
||||
}
|
||||
await writeFile(
|
||||
join(primaryHome, 'config.toml'),
|
||||
[
|
||||
`state_dir = ${JSON.stringify(primaryState)}`,
|
||||
`workspace_dir = ${JSON.stringify(missingWorkspace)}`,
|
||||
'',
|
||||
'[llm]',
|
||||
'provider = "ollama"',
|
||||
'model = "primary-must-not-run"',
|
||||
'base_url = "http://127.0.0.1:9/v1"',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
)
|
||||
await writeFile(
|
||||
primaryCredential,
|
||||
JSON.stringify({
|
||||
provider: 'ollama',
|
||||
model: 'primary-must-not-run',
|
||||
baseUrl: 'http://127.0.0.1:9/v1',
|
||||
encryptedApiKey: PRIMARY_SENTINEL,
|
||||
modelRoutingMode: 'direct',
|
||||
routerMode: 'disabled',
|
||||
searchProvider: 'duckduckgo',
|
||||
encryption: 'plain',
|
||||
createdAt: '2026-07-11T00:00:00.000Z',
|
||||
updatedAt: '2026-07-11T00:00:00.000Z',
|
||||
}, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
runPython(
|
||||
'import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); '
|
||||
+ 'c.execute("CREATE TABLE synthetic_primary_sessions (id TEXT PRIMARY KEY, body TEXT)"); '
|
||||
+ 'c.execute("INSERT INTO synthetic_primary_sessions VALUES (?, ?)", '
|
||||
+ '("primary-session", "primary transcript must remain unchanged")); c.commit(); c.close()',
|
||||
[primaryDatabase],
|
||||
)
|
||||
|
||||
const primaryBefore = await snapshotPrimary(primaryHome, primaryCredential)
|
||||
const fakeProvider = await startFakeProvider()
|
||||
const scrubbedEnvironmentProbe = launchEnvironment(isolatedHome, fakeProvider.port, {
|
||||
...process.env,
|
||||
OPENAI_API_KEY: 'synthetic-real-provider-key-must-not-leak',
|
||||
AWS_PROFILE: 'synthetic-real-provider-profile-must-not-leak',
|
||||
OPENSQUILLA_STATE_DIR: '/synthetic/external/state/must-not-leak',
|
||||
})
|
||||
assert.equal(scrubbedEnvironmentProbe.OPENAI_API_KEY, undefined)
|
||||
assert.equal(scrubbedEnvironmentProbe.AWS_PROFILE, undefined)
|
||||
assert.equal(scrubbedEnvironmentProbe.OPENSQUILLA_STATE_DIR, undefined)
|
||||
assert.equal(scrubbedEnvironmentProbe.HTTP_PROXY, 'http://127.0.0.1:1')
|
||||
assert.equal(scrubbedEnvironmentProbe.NO_PROXY, '127.0.0.1,localhost')
|
||||
let app
|
||||
try {
|
||||
app = await launchDesktop(userData, isolatedHome, fakeProvider.port)
|
||||
const recovery = await recoveryPage(app)
|
||||
assert.equal(await recovery.locator('#recoveryCode').innerText(), 'effective_workspace_missing')
|
||||
assert.equal(await recovery.locator('#copyCredential').isChecked(), false)
|
||||
if (screenshotPath) {
|
||||
await mkdir(dirname(screenshotPath), { recursive: true })
|
||||
await recovery.screenshot({ path: screenshotPath })
|
||||
}
|
||||
await recovery.locator('#createRecovery').click()
|
||||
|
||||
const setup = await onboardingPage(app)
|
||||
await setup.locator('[data-screen="0"].active .next-button').click()
|
||||
await setup.locator('[data-screen="1"].active').waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await setup.locator('#providerMoreToggle').click()
|
||||
await setup.locator('#providerGrid [data-provider="minimax_openai"]').click()
|
||||
await setup.locator('#baseUrl').fill(`http://127.0.0.1:${fakeProvider.port}/v1`)
|
||||
await setup.locator('#model').fill('synthetic-recovery-model')
|
||||
await setup.locator('#apiKey').fill(RECOVERY_SYNTHETIC_KEY)
|
||||
await setup.locator('[data-screen="1"].active .next-button').click()
|
||||
await setup.locator('[data-screen="4"].active').waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await setup.locator('#finish').click()
|
||||
|
||||
const firstControl = await controlPage(app)
|
||||
const recoveryIds = (await readdir(join(userData, 'recovery-profiles'))).sort()
|
||||
assert.equal(recoveryIds.length, 1)
|
||||
const recoveryId = recoveryIds[0]
|
||||
assert.match(recoveryId, /^[0-9a-f-]{36}$/i)
|
||||
const recoveryRoot = join(userData, 'recovery-profiles', recoveryId)
|
||||
const recoveryHome = join(recoveryRoot, 'opensquilla')
|
||||
const recoveryWorkspace = join(recoveryHome, 'workspace')
|
||||
const recoveryState = join(recoveryHome, 'state')
|
||||
const recoveryCredential = await readFile(join(recoveryRoot, 'desktop-credential.json'), 'utf8')
|
||||
assert.doesNotMatch(recoveryCredential, new RegExp(PRIMARY_SENTINEL))
|
||||
assert.equal(JSON.parse(recoveryCredential).provider, 'minimax_openai')
|
||||
for (const name of ['USER.md', 'SOUL.md', 'IDENTITY.md', 'MEMORY.md']) {
|
||||
assert.equal((await lstat(join(recoveryWorkspace, name))).isFile(), true)
|
||||
}
|
||||
assert.deepEqual(await snapshotPrimary(primaryHome, primaryCredential), primaryBefore)
|
||||
|
||||
await sendChat(firstControl, FIRST_PROMPT)
|
||||
await waitFor(
|
||||
() => fakeProvider.requests.some((item) => JSON.stringify(item.payload).includes(FIRST_PROMPT)),
|
||||
'first prompt at local fake provider',
|
||||
)
|
||||
assert.deepEqual(await snapshotPrimary(primaryHome, primaryCredential), primaryBefore)
|
||||
|
||||
await app.close()
|
||||
app = null
|
||||
const recoveryDatabase = join(recoveryState, 'sessions.db')
|
||||
const firstTranscript = JSON.parse(runPython(
|
||||
'import json,sqlite3,sys; c=sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True); '
|
||||
+ 'rows=c.execute("SELECT role,content FROM transcript_entries ORDER BY id").fetchall(); '
|
||||
+ 'c.close(); print(json.dumps(rows))',
|
||||
[recoveryDatabase],
|
||||
))
|
||||
assert(firstTranscript.some(([role, content]) => role === 'user' && String(content).includes(FIRST_PROMPT)))
|
||||
assert(firstTranscript.some(([role, content]) => role === 'assistant' && String(content).includes(REPLY)))
|
||||
assert.deepEqual(await snapshotPrimary(primaryHome, primaryCredential), primaryBefore)
|
||||
|
||||
const persistedBeforeRestart = JSON.parse(
|
||||
await readFile(join(userData, 'desktop-profile-context.json'), 'utf8'),
|
||||
)
|
||||
assert.equal(persistedBeforeRestart.active_profile_kind, 'recovery')
|
||||
assert.equal(persistedBeforeRestart.active_recovery_id, recoveryId)
|
||||
|
||||
app = await launchDesktop(userData, isolatedHome, fakeProvider.port)
|
||||
const restartedRecovery = await recoveryPage(app)
|
||||
assert.equal(
|
||||
await restartedRecovery.locator('#recoveryCode').innerText(),
|
||||
'desktop_recovery_profile_confirmation_required',
|
||||
)
|
||||
assert.equal(
|
||||
await restartedRecovery.locator('#recoveryTitle').innerText(),
|
||||
'Confirm recovery profile',
|
||||
)
|
||||
assert.equal(
|
||||
await restartedRecovery.locator('#recoveryIntro').innerText(),
|
||||
'OpenSquilla is waiting for you to confirm the selected isolated recovery profile before it starts. This does not mean your primary profile is unsafe.',
|
||||
)
|
||||
const persistedWhileBlocked = JSON.parse(
|
||||
await readFile(join(userData, 'desktop-profile-context.json'), 'utf8'),
|
||||
)
|
||||
assert.equal(persistedWhileBlocked.active_profile_kind, 'recovery')
|
||||
assert.equal(persistedWhileBlocked.active_recovery_id, recoveryId)
|
||||
const existingOptions = await restartedRecovery.locator('#recoveryProfiles option').evaluateAll((items) => (
|
||||
items.map((item) => ({ value: item.value, label: item.textContent || '' }))
|
||||
))
|
||||
assert(existingOptions.some((item) => item.value === recoveryId && item.label.includes(recoveryHome)))
|
||||
await restartedRecovery.locator('#recoveryProfiles').selectOption(recoveryId)
|
||||
await restartedRecovery.locator('#continueRecovery').click()
|
||||
|
||||
const secondControl = await controlPage(app)
|
||||
const activeState = await secondControl.evaluate(() => window.opensquillaDesktop.getRecoveryState())
|
||||
assert.equal(activeState.activeProfile.kind, 'recovery')
|
||||
assert.equal(activeState.activeProfile.recoveryId, recoveryId)
|
||||
assert.equal(activeState.activeProfile.home, recoveryHome)
|
||||
const persistedConversation = secondControl.locator('.sidebar-history-item', {
|
||||
hasText: 'Recovery chat',
|
||||
})
|
||||
await persistedConversation.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await persistedConversation.click()
|
||||
await waitFor(() => secondControl.url().includes('?session='), 'persisted recovery chat route')
|
||||
await secondControl.locator('.msg-user').filter({ hasText: FIRST_PROMPT }).last().waitFor({
|
||||
state: 'visible',
|
||||
timeout: 30_000,
|
||||
})
|
||||
await secondControl.locator('.msg-ai').filter({ hasText: REPLY }).last().waitFor({
|
||||
state: 'visible',
|
||||
timeout: 30_000,
|
||||
})
|
||||
await sendChat(secondControl, SECOND_PROMPT)
|
||||
await waitFor(
|
||||
() => fakeProvider.requests.some((item) => JSON.stringify(item.payload).includes(SECOND_PROMPT)),
|
||||
'second prompt at local fake provider',
|
||||
)
|
||||
|
||||
// Reset is a credential/onboarding action, not a profile wipe. Trigger it
|
||||
// from the live Control UI and prove that config, identity Markdown, and the
|
||||
// recovery chat database remain in place after the gateway has drained.
|
||||
const recoveryConfigBeforeReset = await readFile(join(recoveryHome, 'config.toml'))
|
||||
await secondControl.evaluate(() => {
|
||||
void window.opensquillaDesktop.resetDesktopSettings()
|
||||
return true
|
||||
})
|
||||
await onboardingPage(app)
|
||||
assert.deepEqual(
|
||||
await readFile(join(recoveryHome, 'config.toml')),
|
||||
recoveryConfigBeforeReset,
|
||||
'Reset setup must preserve config.toml byte-for-byte',
|
||||
)
|
||||
await assert.rejects(
|
||||
lstat(join(recoveryRoot, 'desktop-credential.json')),
|
||||
(error) => error && error.code === 'ENOENT',
|
||||
)
|
||||
for (const name of ['USER.md', 'SOUL.md', 'IDENTITY.md', 'MEMORY.md']) {
|
||||
assert.equal((await lstat(join(recoveryWorkspace, name))).isFile(), true)
|
||||
}
|
||||
|
||||
await app.close()
|
||||
app = null
|
||||
const finalTranscript = JSON.parse(runPython(
|
||||
'import json,sqlite3,sys; c=sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True); '
|
||||
+ 'rows=c.execute("SELECT role,content FROM transcript_entries ORDER BY id").fetchall(); '
|
||||
+ 'c.close(); print(json.dumps(rows))',
|
||||
[recoveryDatabase],
|
||||
))
|
||||
for (const prompt of [FIRST_PROMPT, SECOND_PROMPT]) {
|
||||
assert(finalTranscript.some(([role, content]) => role === 'user' && String(content).includes(prompt)))
|
||||
}
|
||||
assert(finalTranscript.filter(([role, content]) => role === 'assistant' && String(content).includes(REPLY)).length >= 2)
|
||||
assert.deepEqual(await snapshotPrimary(primaryHome, primaryCredential), primaryBefore)
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
recoveryId,
|
||||
chatsCompleted: 2,
|
||||
explicitContinueVerified: true,
|
||||
resetPreservedProfileData: true,
|
||||
primaryBytesUnchanged: true,
|
||||
localProviderRequests: fakeProvider.requests.length,
|
||||
}, null, 2))
|
||||
} catch (error) {
|
||||
const requestSummary = fakeProvider.requests.map((item) => ({
|
||||
method: item.method,
|
||||
url: item.url,
|
||||
stream: item.payload?.stream,
|
||||
}))
|
||||
const recoveryIds = await readdir(join(userData, 'recovery-profiles')).catch(() => [])
|
||||
const desktopLog = await readFile(join(userData, 'logs', 'desktop.log'), 'utf8').catch(() => '')
|
||||
const gatewayPid = desktopLog
|
||||
.trim()
|
||||
.split('\n')
|
||||
.reverse()
|
||||
.map((line) => {
|
||||
try { return JSON.parse(line) } catch { return null }
|
||||
})
|
||||
.find((entry) => entry?.event === 'gateway_spawned' && Number.isSafeInteger(entry.pid))
|
||||
?.pid
|
||||
let gatewayProcessTree = []
|
||||
const gatewaySamples = []
|
||||
let faulthandlerSignal = { attempted: false }
|
||||
let sessionsDbLsof = { attempted: false }
|
||||
if (process.platform === 'darwin' && gatewayPid && process.env.CI_REPORT_DIR) {
|
||||
const processResult = spawnSync('/bin/ps', ['-axo', 'pid=,ppid=,command='], {
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
})
|
||||
const processes = String(processResult.stdout || '')
|
||||
.split('\n')
|
||||
.map((line) => line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/))
|
||||
.filter(Boolean)
|
||||
.map((match) => ({ pid: Number(match[1]), ppid: Number(match[2]), command: match[3] }))
|
||||
const descendantPids = new Set([gatewayPid])
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (const candidate of processes) {
|
||||
if (!descendantPids.has(candidate.ppid) || descendantPids.has(candidate.pid)) continue
|
||||
descendantPids.add(candidate.pid)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
gatewayProcessTree = processes.filter((candidate) => descendantPids.has(candidate.pid))
|
||||
for (const candidate of gatewayProcessTree) {
|
||||
const samplePath = join(
|
||||
process.env.CI_REPORT_DIR,
|
||||
`gateway-process-${candidate.pid}.sample.txt`,
|
||||
)
|
||||
const result = spawnSync(
|
||||
'/usr/bin/sample',
|
||||
[String(candidate.pid), '3', '-file', samplePath],
|
||||
{ encoding: 'utf8', timeout: 10_000 },
|
||||
)
|
||||
gatewaySamples.push({
|
||||
pid: candidate.pid,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
error: result.error?.message || '',
|
||||
stderr: String(result.stderr || '').slice(-1_000),
|
||||
})
|
||||
}
|
||||
if (recoveryIds.length === 1) {
|
||||
const sessionsDb = join(
|
||||
userData,
|
||||
'recovery-profiles',
|
||||
recoveryIds[0],
|
||||
'opensquilla',
|
||||
'state',
|
||||
'sessions.db',
|
||||
)
|
||||
const result = spawnSync('/usr/sbin/lsof', ['-n', '-P', '--', sessionsDb], {
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
})
|
||||
const output = `${result.stdout || ''}${result.stderr || ''}`
|
||||
await writeFile(join(process.env.CI_REPORT_DIR, 'sessions-db-lsof.txt'), output, 'utf8')
|
||||
sessionsDbLsof = { attempted: true, status: result.status, bytes: output.length }
|
||||
}
|
||||
const pythonChild = gatewayProcessTree.find((candidate) => (
|
||||
candidate.pid !== gatewayPid && /(?:^|[/\\])python(?:3(?:\.\d+)?)?(?:\s|$)/i.test(candidate.command)
|
||||
))
|
||||
if (pythonChild) {
|
||||
try {
|
||||
process.kill(pythonChild.pid, 'SIGABRT')
|
||||
faulthandlerSignal = { attempted: true, pid: pythonChild.pid, sent: true }
|
||||
await delay(1_000)
|
||||
} catch (signalError) {
|
||||
faulthandlerSignal = {
|
||||
attempted: true,
|
||||
pid: pythonChild.pid,
|
||||
sent: false,
|
||||
error: signalError?.message || String(signalError),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const gatewayLog = recoveryIds.length === 1
|
||||
? await readFile(
|
||||
join(userData, 'recovery-profiles', recoveryIds[0], 'logs', 'gateway.log'),
|
||||
'utf8',
|
||||
).catch(() => '')
|
||||
: ''
|
||||
const debugLog = recoveryIds.length === 1
|
||||
? await readFile(
|
||||
join(userData, 'recovery-profiles', recoveryIds[0], 'opensquilla', 'logs', 'debug.log'),
|
||||
'utf8',
|
||||
).catch(() => '')
|
||||
: ''
|
||||
const recoveryStateEntries = recoveryIds.length === 1
|
||||
? await readdir(
|
||||
join(userData, 'recovery-profiles', recoveryIds[0], 'opensquilla', 'state'),
|
||||
{ withFileTypes: true },
|
||||
).then((entries) => entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
kind: entry.isFile() ? 'file' : entry.isDirectory() ? 'directory' : 'other',
|
||||
}))).catch(() => [])
|
||||
: []
|
||||
console.error(JSON.stringify({
|
||||
requestSummary,
|
||||
desktopLogTail: desktopLog.slice(-4000),
|
||||
gatewayProcessTree,
|
||||
gatewaySamples,
|
||||
faulthandlerSignal,
|
||||
sessionsDbLsof,
|
||||
gatewayLogTail: gatewayLog.slice(-4000),
|
||||
debugLogTail: debugLog.slice(-8000),
|
||||
recoveryStateEntries,
|
||||
}, null, 2))
|
||||
throw error
|
||||
} finally {
|
||||
await app?.close().catch(() => {})
|
||||
await fakeProvider.close().catch(() => {})
|
||||
await rm(root, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
macCodeSignatureIsAdHoc,
|
||||
secretStorageBackendForPolicy,
|
||||
shouldUseChromiumMockKeychainForPolicy,
|
||||
} from '../dist/secret-storage-policy.js'
|
||||
|
||||
assert.equal(macCodeSignatureIsAdHoc('Signature=adhoc'), true)
|
||||
assert.equal(macCodeSignatureIsAdHoc('CodeDirectory flags=0x10002(adhoc,runtime)'), true)
|
||||
assert.equal(macCodeSignatureIsAdHoc('Authority=Developer ID Application: OpenSquilla'), false)
|
||||
|
||||
assert.equal(secretStorageBackendForPolicy({
|
||||
envMode: undefined,
|
||||
platform: 'darwin',
|
||||
appPackaged: true,
|
||||
codesignDiagnostic: 'Signature=adhoc',
|
||||
}), 'plain')
|
||||
|
||||
assert.equal(secretStorageBackendForPolicy({
|
||||
envMode: undefined,
|
||||
platform: 'darwin',
|
||||
appPackaged: true,
|
||||
codesignDiagnostic: 'Authority=Developer ID Application: OpenSquilla',
|
||||
}), 'safeStorage')
|
||||
|
||||
assert.equal(secretStorageBackendForPolicy({
|
||||
envMode: 'safeStorage',
|
||||
platform: 'darwin',
|
||||
appPackaged: true,
|
||||
codesignDiagnostic: 'Signature=adhoc',
|
||||
}), 'safeStorage')
|
||||
|
||||
assert.equal(secretStorageBackendForPolicy({
|
||||
envMode: 'plain',
|
||||
platform: 'darwin',
|
||||
appPackaged: true,
|
||||
codesignDiagnostic: 'Authority=Developer ID Application: OpenSquilla',
|
||||
}), 'plain')
|
||||
|
||||
assert.equal(secretStorageBackendForPolicy({
|
||||
envMode: undefined,
|
||||
platform: 'win32',
|
||||
appPackaged: true,
|
||||
codesignDiagnostic: '',
|
||||
}), 'safeStorage')
|
||||
|
||||
assert.equal(shouldUseChromiumMockKeychainForPolicy({
|
||||
envMode: undefined,
|
||||
platform: 'darwin',
|
||||
appPackaged: true,
|
||||
codesignDiagnostic: 'Signature=adhoc',
|
||||
}), true)
|
||||
|
||||
assert.equal(shouldUseChromiumMockKeychainForPolicy({
|
||||
envMode: 'safeStorage',
|
||||
platform: 'darwin',
|
||||
appPackaged: true,
|
||||
codesignDiagnostic: 'Signature=adhoc',
|
||||
}), false)
|
||||
|
||||
assert.equal(shouldUseChromiumMockKeychainForPolicy({
|
||||
envMode: undefined,
|
||||
platform: 'darwin',
|
||||
appPackaged: true,
|
||||
codesignDiagnostic: 'Authority=Developer ID Application: OpenSquilla',
|
||||
}), false)
|
||||
|
||||
assert.equal(shouldUseChromiumMockKeychainForPolicy({
|
||||
envMode: undefined,
|
||||
platform: 'win32',
|
||||
appPackaged: true,
|
||||
codesignDiagnostic: '',
|
||||
}), false)
|
||||
|
||||
console.log('Secret storage policy tests passed.')
|
||||
@@ -0,0 +1,103 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, 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 recoveryId = '11234567-89ab-4cde-8fab-0123456789ab'
|
||||
|
||||
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)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}: ${lastError?.message || lastError || ''}`)
|
||||
}
|
||||
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'opensquilla-unsafe-profile-test-')))
|
||||
const userData = join(root, 'user-data')
|
||||
const isolatedHome = join(root, 'home')
|
||||
const outside = join(root, 'outside')
|
||||
const recoveryRoot = join(userData, 'recovery-profiles')
|
||||
const selectedRoot = join(recoveryRoot, recoveryId)
|
||||
|
||||
await mkdir(recoveryRoot, { recursive: true })
|
||||
await mkdir(isolatedHome, { recursive: true })
|
||||
await mkdir(outside, { recursive: true })
|
||||
const unsafeUpdateState = join(outside, 'opensquilla', 'state', 'desktop-update.json')
|
||||
const unsafeUpdateBytes = JSON.stringify({
|
||||
snoozedVersion: '0.0.1',
|
||||
snoozedUntil: '2099-01-01T00:00:00.000Z',
|
||||
}, null, 2)
|
||||
await mkdir(dirname(unsafeUpdateState), { recursive: true })
|
||||
await writeFile(unsafeUpdateState, unsafeUpdateBytes, 'utf8')
|
||||
await symlink(outside, selectedRoot, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
await writeFile(
|
||||
join(userData, 'desktop-profile-context.json'),
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
active_profile_kind: 'recovery',
|
||||
active_recovery_id: recoveryId,
|
||||
attention_acknowledgement: null,
|
||||
updated_at: '2026-07-11T00:00:00.000Z',
|
||||
}, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
let app
|
||||
try {
|
||||
app = await electron.launch({
|
||||
args: ['--use-mock-keychain', `--user-data-dir=${userData}`, packageRoot],
|
||||
env: {
|
||||
...process.env,
|
||||
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: '18896',
|
||||
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE: '1',
|
||||
OPENSQUILLA_DESKTOP_MOCK_UPDATE_VERSION: '9.9.9',
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
},
|
||||
})
|
||||
|
||||
const page = await waitFor(async () => {
|
||||
for (const candidate of app.windows()) {
|
||||
if (candidate.isClosed()) continue
|
||||
await candidate.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
|
||||
if (await candidate.locator('#recoveryPanel.visible').count().catch(() => 0)) return candidate
|
||||
}
|
||||
return null
|
||||
}, 'unsafe recovery selection page')
|
||||
|
||||
assert.equal(
|
||||
await page.locator('#recoveryCode').innerText(),
|
||||
'desktop_selected_recovery_profile_unsafe',
|
||||
)
|
||||
await delay(1_000)
|
||||
assert.deepEqual(await readdir(outside), ['opensquilla'])
|
||||
assert.equal(
|
||||
await readFile(unsafeUpdateState, 'utf8'),
|
||||
unsafeUpdateBytes,
|
||||
'updater persistence must not read or rewrite an unsafe selected recovery profile',
|
||||
)
|
||||
console.log(JSON.stringify({ ok: true, stableCode: 'desktop_selected_recovery_profile_unsafe' }))
|
||||
} finally {
|
||||
await app?.close().catch(() => {})
|
||||
await rm(root, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
parseOpenSquillaReleaseTag,
|
||||
selectMacPrereleaseCandidate,
|
||||
} from '../dist/update-feed-resolver.js'
|
||||
|
||||
// This exercises the resolver shipped after Preview 2. It proves that clients
|
||||
// containing this resolver can select later releases; it does not prove that
|
||||
// the already-published Preview 1/2 binaries can discover Preview 3.
|
||||
|
||||
// --- tag parsing: PEP440 rc, semver rc, stable, and rejects ---
|
||||
assert.deepEqual(parseOpenSquillaReleaseTag('v0.5.0rc2'), { base: '0.5.0', rc: 2 })
|
||||
assert.deepEqual(parseOpenSquillaReleaseTag('0.5.0-rc2'), { base: '0.5.0', rc: 2 })
|
||||
assert.deepEqual(parseOpenSquillaReleaseTag('v0.5.0-rc.3'), { base: '0.5.0', rc: 3 })
|
||||
assert.deepEqual(parseOpenSquillaReleaseTag('v0.5.0'), { base: '0.5.0', rc: null })
|
||||
assert.equal(parseOpenSquillaReleaseTag('website-2026-01'), null)
|
||||
assert.equal(parseOpenSquillaReleaseTag('v0.5'), null)
|
||||
|
||||
const withMacFeed = (tag) => ({ tag_name: tag, assets: [{ name: 'latest-mac.yml' }] })
|
||||
const noMacFeed = (tag) => ({ tag_name: tag, assets: [{ name: 'OpenSquilla-mac.zip' }] })
|
||||
|
||||
// 1. A resolver-enabled client on 0.5.0-rc1 sees v0.5.0rc2 (PEP440 tag).
|
||||
{
|
||||
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 1 }, [
|
||||
withMacFeed('v0.5.0rc2'),
|
||||
withMacFeed('v0.5.0rc1'),
|
||||
])
|
||||
assert.ok(c, 'rc1 should find rc2')
|
||||
assert.equal(c.tag, 'v0.5.0rc2')
|
||||
assert.equal(c.version, '0.5.0-rc2')
|
||||
assert.equal(c.feedUrl, 'https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc2')
|
||||
}
|
||||
|
||||
// 2. A resolver-enabled client on 0.5.0-rc2 sees v0.5.0rc3.
|
||||
{
|
||||
const c = selectMacPrereleaseCandidate(
|
||||
{ base: '0.5.0', rc: 2 },
|
||||
[withMacFeed('v0.5.0rc3'), withMacFeed('v0.5.0rc2')],
|
||||
)
|
||||
assert.ok(c)
|
||||
assert.equal(c.tag, 'v0.5.0rc3')
|
||||
assert.equal(c.version, '0.5.0-rc3')
|
||||
}
|
||||
|
||||
// 3. 0.5.0-rc2 sees the final stable v0.5.0 (stable outranks a later rc).
|
||||
{
|
||||
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 2 }, [
|
||||
withMacFeed('v0.5.0'),
|
||||
withMacFeed('v0.5.0rc3'),
|
||||
withMacFeed('v0.5.0rc2'),
|
||||
])
|
||||
assert.ok(c, 'rc2 should find a candidate')
|
||||
assert.equal(c.tag, 'v0.5.0')
|
||||
assert.equal(c.version, '0.5.0')
|
||||
}
|
||||
|
||||
// 2b. Two-digit rc ordering is numeric, not string: rc9 sees rc10 (not the
|
||||
// reverse). electron-updater's own semver gate sorts rc10 below rc9, which is
|
||||
// why the resolver path also sets allowDowngrade — see main.ts.
|
||||
{
|
||||
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 9 }, [
|
||||
withMacFeed('v0.5.0rc10'),
|
||||
withMacFeed('v0.5.0rc9'),
|
||||
])
|
||||
assert.ok(c, 'rc9 should find rc10')
|
||||
assert.equal(c.tag, 'v0.5.0rc10')
|
||||
assert.equal(c.version, '0.5.0-rc10')
|
||||
}
|
||||
// rc10 does not regress to rc9.
|
||||
{
|
||||
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 10 }, [
|
||||
withMacFeed('v0.5.0rc10'),
|
||||
withMacFeed('v0.5.0rc9'),
|
||||
])
|
||||
assert.equal(c, null, 'rc10 must not pick the lower rc9')
|
||||
}
|
||||
|
||||
// 4. A prerelease does NOT jump to a different base's preview (0.5.0-rc2 ignores v0.6.0rc1).
|
||||
{
|
||||
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 2 }, [
|
||||
withMacFeed('v0.6.0rc1'),
|
||||
withMacFeed('v0.5.0rc2'),
|
||||
])
|
||||
assert.equal(c, null, 'rc2 must not cross to a different base')
|
||||
}
|
||||
|
||||
// 4a. A newer same-base release without latest-mac.yml is skipped.
|
||||
{
|
||||
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 1 }, [noMacFeed('v0.5.0rc2')])
|
||||
assert.equal(c, null, 'candidate without latest-mac.yml is skipped')
|
||||
}
|
||||
|
||||
// 4b. When the highest release lacks the feed, fall back to the highest that has it.
|
||||
{
|
||||
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 1 }, [
|
||||
noMacFeed('v0.5.0rc3'),
|
||||
withMacFeed('v0.5.0rc2'),
|
||||
])
|
||||
assert.ok(c, 'should fall back to rc2 which has the feed')
|
||||
assert.equal(c.tag, 'v0.5.0rc2')
|
||||
}
|
||||
|
||||
// 5. No newer same-base release → no candidate (current rc is the latest).
|
||||
{
|
||||
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 2 }, [withMacFeed('v0.5.0rc2')])
|
||||
assert.equal(c, null, 'only the current rc exists → up to date')
|
||||
}
|
||||
|
||||
// 6. Draft releases are ignored.
|
||||
{
|
||||
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 1 }, [
|
||||
{ tag_name: 'v0.5.0rc2', draft: true, assets: [{ name: 'latest-mac.yml' }] },
|
||||
])
|
||||
assert.equal(c, null, 'draft releases are not upgrade candidates')
|
||||
}
|
||||
|
||||
console.log('Update resolver tests passed.')
|
||||
@@ -0,0 +1,47 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const packageRoot = resolve(scriptDir, '..')
|
||||
const packageJsonPath = join(packageRoot, 'package.json')
|
||||
const macIconPath = join(packageRoot, 'assets', 'icon.icns')
|
||||
const windowsIconPath = join(packageRoot, 'assets', 'icon.ico')
|
||||
|
||||
const failures = []
|
||||
|
||||
function fail(message) {
|
||||
failures.push(message)
|
||||
}
|
||||
|
||||
function expectEqual(actual, expected, label) {
|
||||
if (actual !== expected) {
|
||||
fail(`${label} must be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(await readFile(packageJsonPath, 'utf8'))
|
||||
const build = pkg.build ?? {}
|
||||
|
||||
if (!existsSync(macIconPath)) {
|
||||
fail(`macOS icon is missing at ${macIconPath}`)
|
||||
}
|
||||
|
||||
if (!existsSync(windowsIconPath)) {
|
||||
fail(`Windows icon is missing at ${windowsIconPath}`)
|
||||
}
|
||||
|
||||
expectEqual(build.mac?.icon, 'assets/icon.icns', 'build.mac.icon')
|
||||
expectEqual(build.dmg?.icon, 'assets/icon.icns', 'build.dmg.icon')
|
||||
expectEqual(build.win?.icon, 'assets/icon.ico', 'build.win.icon')
|
||||
expectEqual(build.nsis?.installerIcon, 'assets/icon.ico', 'build.nsis.installerIcon')
|
||||
expectEqual(build.nsis?.uninstallerIcon, 'assets/icon.ico', 'build.nsis.uninstallerIcon')
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('OpenSquilla desktop icon verification failed:')
|
||||
for (const failure of failures) console.error(`- ${failure}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('OpenSquilla desktop icon verification passed.')
|
||||
@@ -0,0 +1,400 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const packageRoot = resolve(scriptDir, '..')
|
||||
const repoRoot = resolve(packageRoot, '..', '..')
|
||||
const runtimeGatewayDir = join(packageRoot, 'runtime', 'gateway')
|
||||
const sourceMainPath = join(packageRoot, 'src', 'main.ts')
|
||||
const compiledMainPath = join(packageRoot, 'dist', 'main.js')
|
||||
const packageJsonPath = join(packageRoot, 'package.json')
|
||||
const desktopOutputDir = join(repoRoot, 'dist', 'desktop-electron')
|
||||
|
||||
const failures = []
|
||||
|
||||
function fail(message) {
|
||||
failures.push(message)
|
||||
}
|
||||
|
||||
function pathIsFileSync(path) {
|
||||
try {
|
||||
return statSync(path).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function gatewayBinary(root, platform) {
|
||||
const binaryName = platform === 'win32' ? 'opensquilla-gateway.exe' : 'opensquilla-gateway'
|
||||
const candidates = [join(root, 'opensquilla-gateway', binaryName), join(root, binaryName)]
|
||||
const binary = candidates.find(pathIsFileSync)
|
||||
return { binary, candidates }
|
||||
}
|
||||
|
||||
function verificationEnv() {
|
||||
const keys = [
|
||||
'PATH',
|
||||
'Path',
|
||||
'HOME',
|
||||
'USERPROFILE',
|
||||
'TMPDIR',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'SystemRoot',
|
||||
'ComSpec',
|
||||
'PATHEXT',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
]
|
||||
const env = {
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONUTF8: '1',
|
||||
PYTHONIOENCODING: 'utf-8:replace',
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (process.env[key] !== undefined) env[key] = process.env[key]
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
function outputTail(output) {
|
||||
const tail = [output.stdout, output.stderr]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.slice(-12)
|
||||
.join('\n')
|
||||
.trim()
|
||||
|
||||
return tail ? `\nOutput tail:\n${tail}` : ''
|
||||
}
|
||||
|
||||
function requireGatewayBinary(root, label, platform) {
|
||||
const { binary, candidates } = gatewayBinary(root, platform)
|
||||
if (!binary) {
|
||||
fail(`${label} gateway binary is missing; checked ${candidates.join(', ')}`)
|
||||
return null
|
||||
}
|
||||
return binary
|
||||
}
|
||||
|
||||
function verifyGatewayCommand(binary, label, args, options = {}) {
|
||||
const result = spawnSync(binary, args, {
|
||||
cwd: dirname(binary),
|
||||
encoding: 'utf8',
|
||||
env: verificationEnv(),
|
||||
input: options.input,
|
||||
timeout: options.timeout ?? 30000,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
const commandLabel = `${label} gateway command ${args.join(' ')}`
|
||||
if (result.error) {
|
||||
fail(`${commandLabel} could not start: ${result.error.message}${outputTail(result)}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const exitReason = result.signal ? `signal ${result.signal}` : `exit ${result.status}`
|
||||
fail(`${commandLabel} failed with ${exitReason}${outputTail(result)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function verifyMacLightgbmRuntime(files, label) {
|
||||
const lightgbmLibs = files.filter((path) => path.endsWith(join('lightgbm', 'lib', 'lib_lightgbm.dylib')))
|
||||
if (lightgbmLibs.length === 0) {
|
||||
fail(`${label} runtime is missing lightgbm/lib/lib_lightgbm.dylib`)
|
||||
return
|
||||
}
|
||||
|
||||
for (const lightgbmLib of lightgbmLibs) {
|
||||
const bundledLibomp = join(dirname(lightgbmLib), 'libomp.dylib')
|
||||
if (!files.includes(bundledLibomp)) {
|
||||
fail(`${label} runtime is missing bundled libomp.dylib next to ${lightgbmLib}`)
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') continue
|
||||
const result = spawnSync('otool', ['-L', lightgbmLib], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.error) {
|
||||
fail(`${label} could not inspect ${lightgbmLib} with otool: ${result.error.message}`)
|
||||
} else if (result.status !== 0) {
|
||||
fail(`${label} otool -L failed for ${lightgbmLib}${outputTail(result)}`)
|
||||
} else if (!result.stdout.includes('@loader_path/libomp.dylib')) {
|
||||
fail(`${label} ${lightgbmLib} does not load libomp.dylib via @loader_path`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listFiles(root) {
|
||||
const files = []
|
||||
|
||||
async function walk(dir) {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
fail(`${root} could not be read: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await walk(path)
|
||||
} else if (entry.isFile() && entry.name !== '.DS_Store') {
|
||||
files.push(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root)
|
||||
return files
|
||||
}
|
||||
|
||||
async function verifyRuntime(root, label, { platform, executeCommands }) {
|
||||
if (!existsSync(root)) {
|
||||
fail(`${label} runtime is missing at ${root}`)
|
||||
return
|
||||
}
|
||||
|
||||
const info = await stat(root).catch(() => null)
|
||||
if (!info?.isDirectory()) {
|
||||
fail(`${label} runtime is not a directory: ${root}`)
|
||||
return
|
||||
}
|
||||
|
||||
const files = await listFiles(root)
|
||||
if (files.length === 0) {
|
||||
fail(`${label} runtime is empty: ${root}`)
|
||||
return
|
||||
}
|
||||
|
||||
const compatFile = files.find((path) => path.endsWith(join('opensquilla', 'compat', 'aiosqlite.py')))
|
||||
if (!compatFile) {
|
||||
fail(`${label} runtime is missing opensquilla/compat/aiosqlite.py`)
|
||||
} else {
|
||||
const source = await readFile(compatFile, 'utf8')
|
||||
if (!source.includes('async def create_function(') || !source.includes('self._conn.create_function')) {
|
||||
fail(`${label} runtime aiosqlite.py does not contain _AsyncConnection.create_function`)
|
||||
}
|
||||
}
|
||||
|
||||
const binary = requireGatewayBinary(root, label, platform)
|
||||
if (executeCommands) {
|
||||
if (!binary) return
|
||||
verifyGatewayCommand(binary, label, ['--help'])
|
||||
verifyGatewayCommand(binary, label, ['code-task', '--help'])
|
||||
verifyGatewayCommand(binary, label, ['code-task', 'stage-task-file'], { input: 'desktop package smoke\n' })
|
||||
verifyGatewayCommand(binary, label, ['code-task', 'smoke-imports'], { timeout: 120000 })
|
||||
verifyGatewayCommand(binary, label, ['code-task', 'smoke-router'], { timeout: 120000 })
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
verifyMacLightgbmRuntime(files, label)
|
||||
}
|
||||
}
|
||||
|
||||
function verifyMainProcess(source, label) {
|
||||
for (const expected of [
|
||||
'gatewayStartPromise',
|
||||
'openOrResumeDesktopApp',
|
||||
'ensureGatewayStarted',
|
||||
'isCurrentWindowAtControlUi',
|
||||
]) {
|
||||
if (!source.includes(expected)) fail(`${label} main process is missing ${expected}`)
|
||||
}
|
||||
|
||||
const helperIndex = source.indexOf('async function openOrResumeDesktopApp')
|
||||
const createIndex = source.indexOf('await createMainWindow()', helperIndex)
|
||||
const ensureIndex = source.indexOf('ensureGatewayStarted()', helperIndex)
|
||||
if (helperIndex === -1 || createIndex === -1 || ensureIndex === -1 || createIndex > ensureIndex) {
|
||||
fail(`${label} main process does not create the desktop window before gateway startup`)
|
||||
}
|
||||
|
||||
if (!/app\.on\(['"]activate['"][\s\S]{0,240}openOrResumeDesktopApp/.test(source)) {
|
||||
fail(`${label} main process activate handler does not route through openOrResumeDesktopApp`)
|
||||
}
|
||||
if (!/second-instance[\s\S]{0,240}openOrResumeDesktopApp/.test(source)) {
|
||||
fail(`${label} main process second-instance handler does not route through openOrResumeDesktopApp`)
|
||||
}
|
||||
|
||||
const loadCurrentIndex = source.indexOf('async function loadControlUiIntoCurrentWindow')
|
||||
const controlGuardIndex = source.indexOf('isCurrentWindowAtControlUi(window, gatewayUrl)', loadCurrentIndex)
|
||||
const controlLoadIndex = source.indexOf('loadControlUi(window, gatewayUrl)', loadCurrentIndex)
|
||||
if (
|
||||
loadCurrentIndex === -1
|
||||
|| controlGuardIndex === -1
|
||||
|| controlLoadIndex === -1
|
||||
|| controlGuardIndex > controlLoadIndex
|
||||
) {
|
||||
fail(`${label} main process reloads the Control UI before checking whether it is already loaded`)
|
||||
}
|
||||
|
||||
const onboardingIndex = source.indexOf('async function runOnboarding')
|
||||
const onboardingWindowIndex = source.indexOf('onboardingWindow = new BrowserWindow', onboardingIndex)
|
||||
const parentIndex = source.indexOf('const parentWindow = currentMainWindow()', onboardingIndex)
|
||||
const parentOptionIndex = source.indexOf('parent: parentWindow ?? undefined', onboardingWindowIndex)
|
||||
const modalOptionIndex = source.indexOf('modal: Boolean(parentWindow)', onboardingWindowIndex)
|
||||
if (
|
||||
onboardingIndex === -1
|
||||
|| onboardingWindowIndex === -1
|
||||
|| parentIndex === -1
|
||||
|| parentOptionIndex === -1
|
||||
|| modalOptionIndex === -1
|
||||
|| parentIndex > onboardingWindowIndex
|
||||
) {
|
||||
fail(`${label} main process does not make first-run onboarding an owned modal child window`)
|
||||
}
|
||||
|
||||
const focusIndex = source.indexOf('function focusMainWindow')
|
||||
const focusSource = focusIndex === -1 ? '' : source.slice(focusIndex, focusIndex + 800)
|
||||
const onboardingFocusMatch = /if\s*\(\s*focusOnboardingWindow\(\)\s*\)\s*(?:\{\s*)?return true\s*;?/.exec(focusSource)
|
||||
const mainFocusMatch =
|
||||
/if\s*\(\s*!mainWindow\s*\|\|\s*mainWindow\.isDestroyed\(\)\s*\)\s*(?:\{\s*)?return false\s*;?/.exec(
|
||||
focusSource,
|
||||
)
|
||||
if (
|
||||
focusIndex === -1
|
||||
|| !onboardingFocusMatch
|
||||
|| !mainFocusMatch
|
||||
|| onboardingFocusMatch.index > mainFocusMatch.index
|
||||
) {
|
||||
fail(`${label} main process does not prefer the onboarding window when focusing`)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySourceMain() {
|
||||
if (!existsSync(sourceMainPath)) {
|
||||
fail(`source Electron main process is missing at ${sourceMainPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
verifyMainProcess(await readFile(sourceMainPath, 'utf8'), 'source')
|
||||
}
|
||||
|
||||
async function verifyInstallerDataPolicy() {
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'))
|
||||
if (packageJson.build?.nsis?.deleteAppDataOnUninstall !== false) {
|
||||
fail('NSIS uninstall must preserve Desktop profile data (deleteAppDataOnUninstall=false)')
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCompiledMain() {
|
||||
if (!existsSync(compiledMainPath)) {
|
||||
fail(`compiled Electron main process is missing at ${compiledMainPath}; run npm run build first`)
|
||||
return
|
||||
}
|
||||
|
||||
verifyMainProcess(await readFile(compiledMainPath, 'utf8'), 'compiled')
|
||||
}
|
||||
|
||||
async function findGeneratedBundles(root) {
|
||||
const bundles = []
|
||||
const seenResourcesDirs = new Set()
|
||||
if (!existsSync(root)) return bundles
|
||||
|
||||
function addBundle(bundle) {
|
||||
if (seenResourcesDirs.has(bundle.resourcesDir)) return
|
||||
seenResourcesDirs.add(bundle.resourcesDir)
|
||||
bundles.push(bundle)
|
||||
}
|
||||
|
||||
async function walk(dir, depth) {
|
||||
if (depth > 5) return
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.name.endsWith('.app')) {
|
||||
addBundle({
|
||||
label: `app bundle ${path}`,
|
||||
resourcesDir: join(path, 'Contents', 'Resources'),
|
||||
platform: 'darwin',
|
||||
})
|
||||
} else if (entry.name === 'win-unpacked' || entry.name === 'linux-unpacked') {
|
||||
addBundle({
|
||||
label: `generated bundle ${path}`,
|
||||
resourcesDir: join(path, 'resources'),
|
||||
platform: entry.name === 'win-unpacked' ? 'win32' : 'linux',
|
||||
})
|
||||
} else {
|
||||
await walk(path, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root, 0)
|
||||
return bundles
|
||||
}
|
||||
|
||||
async function readAsarText(asarPath, innerPath) {
|
||||
const asar = await import('@electron/asar')
|
||||
return asar.extractFile(asarPath, innerPath).toString('utf8')
|
||||
}
|
||||
|
||||
async function verifyAsarPackageVersion(asarPath, label) {
|
||||
let packageJson
|
||||
try {
|
||||
packageJson = JSON.parse(await readAsarText(asarPath, 'package.json'))
|
||||
} catch (error) {
|
||||
fail(`${label} app.asar package.json could not be inspected: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return
|
||||
}
|
||||
|
||||
const version = typeof packageJson.version === 'string' ? packageJson.version : ''
|
||||
const semverPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
||||
if (!semverPattern.test(version) || /\d(?:a|b|rc)\d+$/.test(version)) {
|
||||
fail(
|
||||
`${label} app.asar package.json version is not npm semver: ${JSON.stringify(version)}; ` +
|
||||
'prereleases must use 0.5.0-rc2 style, not 0.5.0rc2'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyGeneratedBundle({ label, resourcesDir, platform }) {
|
||||
await verifyRuntime(join(resourcesDir, 'runtime', 'gateway'), label, {
|
||||
platform,
|
||||
executeCommands: platform === process.platform,
|
||||
})
|
||||
|
||||
const asarPath = join(resourcesDir, 'app.asar')
|
||||
if (!existsSync(asarPath)) {
|
||||
fail(`${label} is missing app.asar`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyAsarPackageVersion(asarPath, label)
|
||||
verifyMainProcess(await readAsarText(asarPath, 'dist/main.js'), label)
|
||||
} catch (error) {
|
||||
fail(`${label} app.asar could not be inspected: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
await verifyRuntime(runtimeGatewayDir, 'source', { platform: process.platform, executeCommands: true })
|
||||
await verifyInstallerDataPolicy()
|
||||
await verifySourceMain()
|
||||
await verifyCompiledMain()
|
||||
|
||||
const generatedBundles = await findGeneratedBundles(desktopOutputDir)
|
||||
for (const bundle of generatedBundles) {
|
||||
await verifyGeneratedBundle(bundle)
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('OpenSquilla desktop package verification failed:')
|
||||
for (const failure of failures) console.error(`- ${failure}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('OpenSquilla desktop package verification passed.')
|
||||
Reference in New Issue
Block a user