275 lines
10 KiB
JavaScript
275 lines
10 KiB
JavaScript
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(() => {})
|
|
}
|