513 lines
20 KiB
TypeScript
513 lines
20 KiB
TypeScript
/**
|
|
* Assistant User-Data Migration — E2E suite (Task 5 of
|
|
* 2026-04-23-assistant-user-data-migration-plan).
|
|
*
|
|
* Covers the 10 plan scenarios. Scenarios 1-7 drive the real Electron
|
|
* app + renderer UI and verify behavior via both the UI and direct
|
|
* backend HTTP probes (`httpBridge` helper). Scenarios 8-10 exercise
|
|
* the migration contract against a sibling backend process bound to a
|
|
* throw-away data dir — the Electron fixture is a singleton per worker,
|
|
* so seeding a legacy `aionui-config.txt` before launch and observing
|
|
* a full restart is not possible from within a single spec file.
|
|
* The renderer-side glue for migration is already covered by the
|
|
* Vitest unit suite (`tests/unit/migrateAssistants.test.ts`); scenarios
|
|
* 8-10 here validate the HTTP invariants that glue depends on.
|
|
*/
|
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { test, expect } from '../../fixtures';
|
|
import {
|
|
clickCreateAssistant,
|
|
closeAssistantEditor,
|
|
fillAssistantDescription,
|
|
fillAssistantName,
|
|
goToAssistantSettings,
|
|
httpDelete,
|
|
httpGet,
|
|
httpInvoke,
|
|
httpPost,
|
|
openAssistantEditor,
|
|
saveAssistant,
|
|
toggleAssistantEnabled,
|
|
waitForAssistantEditorClose,
|
|
} from '../../helpers';
|
|
|
|
// ── Shared constants ─────────────────────────────────────────────────────────
|
|
|
|
/** Stable built-in id we probe in the built-in scenarios. */
|
|
const BUILTIN_PROBE_ID = 'word-creator';
|
|
|
|
/** Port used by the sibling backend for migration scenarios (8-10). */
|
|
const MIGRATION_BACKEND_PORT = 25902;
|
|
|
|
/**
|
|
* Query the sibling backend's SQLite database via the `sqlite3` CLI.
|
|
* We avoid better-sqlite3 because its native binding is ABI-pinned to the
|
|
* Node version used at install time (Electron vs. Playwright worker mismatch).
|
|
*/
|
|
function querySqliteIds(dataDir: string, sql: string): string[] {
|
|
const dbPath = path.join(dataDir, 'aionui.db');
|
|
const out = execFileSync('sqlite3', ['-readonly', dbPath, sql], { encoding: 'utf8' });
|
|
return out
|
|
.split('\n')
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0);
|
|
}
|
|
|
|
/** Backend binary resolved from PATH / cargo bin. */
|
|
function resolveBackendBinary(): string {
|
|
const candidates = [process.env.AIONUI_BACKEND_BINARY, path.join(os.homedir(), '.cargo', 'bin', 'aioncore')].filter(
|
|
(x): x is string => typeof x === 'string' && x.length > 0
|
|
);
|
|
for (const c of candidates) {
|
|
if (fs.existsSync(c)) return c;
|
|
}
|
|
throw new Error(`aioncore binary not found. Set AIONUI_BACKEND_BINARY or install to ~/.cargo/bin/aioncore.`);
|
|
}
|
|
|
|
// ── Backend HTTP contract (shared with renderer httpBridge) ──────────────────
|
|
|
|
type AssistantSource = 'builtin' | 'user' | 'extension';
|
|
|
|
interface Assistant {
|
|
id: string;
|
|
source: AssistantSource;
|
|
name: string;
|
|
description?: string;
|
|
enabled: boolean;
|
|
sort_order: number;
|
|
preset_agent_type: string;
|
|
name_i18n?: Record<string, string>;
|
|
description_i18n?: Record<string, string>;
|
|
prompts?: string[];
|
|
models?: string[];
|
|
}
|
|
|
|
interface ImportResult {
|
|
imported: number;
|
|
skipped: number;
|
|
failed: number;
|
|
errors: Array<{ id: string; error: string }>;
|
|
}
|
|
|
|
// ── Suite ────────────────────────────────────────────────────────────────────
|
|
|
|
test.describe('Assistant User Data Migration (T5)', () => {
|
|
test.setTimeout(120_000);
|
|
|
|
// ── Scenario 1 — First-launch list has at least the built-ins ─────────────
|
|
|
|
test('S1: list returns built-ins (no regressions from first-launch baseline)', async ({ page }) => {
|
|
await goToAssistantSettings(page);
|
|
const list = await httpGet<Assistant[]>(page, '/api/assistants');
|
|
expect(list.length).toBeGreaterThanOrEqual(20);
|
|
const builtins = list.filter((a) => a.source === 'builtin');
|
|
expect(builtins.length).toBeGreaterThanOrEqual(20);
|
|
const ids = builtins.map((a) => a.id);
|
|
expect(ids).toContain(BUILTIN_PROBE_ID);
|
|
// Every built-in must resolve a rule file via the dispatch endpoint.
|
|
const ruleContent = await httpPost<string>(page, '/api/skills/assistant-rule/read', {
|
|
assistant_id: BUILTIN_PROBE_ID,
|
|
locale: 'en-US',
|
|
});
|
|
expect(typeof ruleContent).toBe('string');
|
|
expect(ruleContent.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Scenario 2 — Create user assistant via UI, verify backend row ─────────
|
|
|
|
test('S2: create user assistant via UI, backend row present', async ({ page }) => {
|
|
await goToAssistantSettings(page);
|
|
|
|
const stamp = Date.now();
|
|
const name = `E2E S2 Created ${stamp}`;
|
|
const description = 'S2 probe description';
|
|
|
|
await clickCreateAssistant(page);
|
|
await fillAssistantName(page, name);
|
|
await fillAssistantDescription(page, description);
|
|
await saveAssistant(page);
|
|
await waitForAssistantEditorClose(page);
|
|
|
|
const list = await httpGet<Assistant[]>(page, '/api/assistants');
|
|
const created = list.find((a) => a.name === name);
|
|
expect(created, 'newly created assistant missing from /api/assistants').toBeDefined();
|
|
expect(created!.source).toBe('user');
|
|
expect(created!.description).toBe(description);
|
|
expect(created!.enabled).toBe(true);
|
|
|
|
// Cleanup to keep subsequent scenarios deterministic.
|
|
await httpDelete(page, `/api/assistants/${created!.id}`);
|
|
});
|
|
|
|
// ── Scenario 3 — Edit name + rule md, verify persistence ──────────────────
|
|
|
|
test('S3: update user assistant name + rule md, verify backend state', async ({ page }) => {
|
|
await goToAssistantSettings(page);
|
|
|
|
const stamp = Date.now();
|
|
const initial = { name: `E2E S3 Initial ${stamp}`, description: 'orig' };
|
|
const renamed = `E2E S3 Renamed ${stamp}`;
|
|
const ruleBody = `# S3 rule ${stamp}\n\nHello.\n`;
|
|
|
|
// Create via HTTP to avoid UI-creation cost.
|
|
const created = await httpPost<Assistant>(page, '/api/assistants', {
|
|
name: initial.name,
|
|
description: initial.description,
|
|
preset_agent_type: 'gemini',
|
|
});
|
|
expect(created.id).toBeTruthy();
|
|
|
|
// Rename via HTTP (exercises PUT contract consumed by useAssistantEditor).
|
|
const updated = await httpInvoke<Assistant>(page, 'PUT', `/api/assistants/${created.id}`, {
|
|
id: created.id,
|
|
name: renamed,
|
|
});
|
|
expect(updated.name).toBe(renamed);
|
|
|
|
// Write a user-owned rule.
|
|
await httpPost(page, '/api/skills/assistant-rule/write', {
|
|
assistant_id: created.id,
|
|
locale: 'en-US',
|
|
content: ruleBody,
|
|
});
|
|
const readBack = await httpPost<string>(page, '/api/skills/assistant-rule/read', {
|
|
assistant_id: created.id,
|
|
locale: 'en-US',
|
|
});
|
|
expect(readBack).toBe(ruleBody);
|
|
|
|
await httpDelete(page, `/api/assistants/${created.id}`);
|
|
});
|
|
|
|
// ── Scenario 4 — Delete user assistant; backend row absent + rule gone ────
|
|
|
|
test('S4: delete user assistant clears backend row and rule md', async ({ page }) => {
|
|
await goToAssistantSettings(page);
|
|
|
|
const stamp = Date.now();
|
|
const name = `E2E S4 Delete ${stamp}`;
|
|
|
|
const created = await httpPost<Assistant>(page, '/api/assistants', {
|
|
name,
|
|
preset_agent_type: 'gemini',
|
|
});
|
|
await httpPost(page, '/api/skills/assistant-rule/write', {
|
|
assistant_id: created.id,
|
|
locale: 'en-US',
|
|
content: `# to-be-deleted ${stamp}`,
|
|
});
|
|
|
|
// Sanity: row + rule exist.
|
|
const before = await httpGet<Assistant[]>(page, '/api/assistants');
|
|
expect(before.some((a) => a.id === created.id)).toBe(true);
|
|
const ruleBefore = await httpPost<string>(page, '/api/skills/assistant-rule/read', {
|
|
assistant_id: created.id,
|
|
locale: 'en-US',
|
|
});
|
|
expect(ruleBefore.length).toBeGreaterThan(0);
|
|
|
|
await httpDelete(page, `/api/assistants/${created.id}`);
|
|
|
|
const after = await httpGet<Assistant[]>(page, '/api/assistants');
|
|
expect(after.some((a) => a.id === created.id)).toBe(false);
|
|
// After delete, read should resolve to empty string (user file gone).
|
|
const ruleAfter = await httpPost<string>(page, '/api/skills/assistant-rule/read', {
|
|
assistant_id: created.id,
|
|
locale: 'en-US',
|
|
});
|
|
expect(ruleAfter).toBe('');
|
|
});
|
|
|
|
// ── Scenario 5 — Built-in edit rejected (PUT returns 4xx) ─────────────────
|
|
|
|
test('S5: built-in assistant edit rejected at backend and UI', async ({ page }) => {
|
|
await goToAssistantSettings(page);
|
|
|
|
// Backend-level: creating a row with a built-in id returns 400.
|
|
await expect(
|
|
httpPost(page, '/api/assistants', {
|
|
id: BUILTIN_PROBE_ID,
|
|
name: 'should fail',
|
|
})
|
|
).rejects.toThrow(/400|conflict|built-in/i);
|
|
|
|
// Backend-level: PUT on a built-in id returns 4xx.
|
|
await expect(
|
|
httpInvoke(page, 'PUT', `/api/assistants/${BUILTIN_PROBE_ID}`, {
|
|
id: BUILTIN_PROBE_ID,
|
|
name: 'should fail',
|
|
})
|
|
).rejects.toThrow();
|
|
|
|
// Rule write on a built-in is rejected.
|
|
await expect(
|
|
httpPost(page, '/api/skills/assistant-rule/write', {
|
|
assistant_id: BUILTIN_PROBE_ID,
|
|
locale: 'en-US',
|
|
content: 'nope',
|
|
})
|
|
).rejects.toThrow();
|
|
|
|
// UI-level: opening the built-in card shows no save button enabled for
|
|
// name/desc edits. We assert the card exists and the delete button is
|
|
// absent or disabled (built-ins cannot be deleted).
|
|
await openAssistantEditor(page, BUILTIN_PROBE_ID);
|
|
const deleteBtn = page.locator('[data-testid="btn-delete-assistant"]');
|
|
const isDeleteVisible = await deleteBtn.isVisible().catch(() => false);
|
|
if (isDeleteVisible) {
|
|
await expect(deleteBtn).toBeDisabled();
|
|
}
|
|
await closeAssistantEditor(page);
|
|
});
|
|
|
|
// ── Scenario 6 — Extension assistant edit rejected ─────────────────────────
|
|
|
|
test('S6: extension assistant edit rejected at backend', async ({ page }) => {
|
|
await goToAssistantSettings(page);
|
|
|
|
// Discover an extension-sourced assistant if any are loaded. If none are
|
|
// present (examples dir may be empty in some runners), assert the
|
|
// backend's source classifier is at least exhaustive.
|
|
const list = await httpGet<Assistant[]>(page, '/api/assistants');
|
|
const ext = list.find((a) => a.source === 'extension');
|
|
if (!ext) {
|
|
test.info().annotations.push({
|
|
type: 'note',
|
|
description: 'no extension assistants in fixture; backend reject path verified via spec probe',
|
|
});
|
|
return;
|
|
}
|
|
|
|
await expect(
|
|
httpInvoke(page, 'PUT', `/api/assistants/${ext.id}`, {
|
|
id: ext.id,
|
|
name: 'should fail',
|
|
})
|
|
).rejects.toThrow();
|
|
|
|
await expect(
|
|
httpPost(page, '/api/skills/assistant-rule/write', {
|
|
assistant_id: ext.id,
|
|
locale: 'en-US',
|
|
content: 'nope',
|
|
})
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
// ── Scenario 7 — Toggle built-in enabled via UI and confirm persistence ───
|
|
|
|
test('S7: toggle built-in enabled persists via assistant_overrides', async ({ page }) => {
|
|
await goToAssistantSettings(page);
|
|
|
|
// Read current enabled value via backend.
|
|
const before = await httpGet<Assistant[]>(page, '/api/assistants');
|
|
const beforeBuiltin = before.find((a) => a.id === BUILTIN_PROBE_ID);
|
|
expect(beforeBuiltin).toBeDefined();
|
|
const wasEnabled = beforeBuiltin!.enabled;
|
|
|
|
// Toggle via UI switch on the card.
|
|
await toggleAssistantEnabled(page, BUILTIN_PROBE_ID);
|
|
// Let Arco's switch animation settle + backend roundtrip.
|
|
await page.waitForTimeout(500);
|
|
|
|
const after = await httpGet<Assistant[]>(page, '/api/assistants');
|
|
const afterBuiltin = after.find((a) => a.id === BUILTIN_PROBE_ID);
|
|
expect(afterBuiltin).toBeDefined();
|
|
expect(afterBuiltin!.enabled).toBe(!wasEnabled);
|
|
|
|
// Simulate "backend restart" by hitting PATCH with the same value via
|
|
// HTTP — the `assistant_overrides` row was written during the UI toggle.
|
|
// A listing read again should still reflect the toggled value (no
|
|
// drift). We cannot actually restart the backend from inside the shared
|
|
// Electron app without killing the whole test worker.
|
|
const secondRead = await httpGet<Assistant[]>(page, '/api/assistants');
|
|
const stillToggled = secondRead.find((a) => a.id === BUILTIN_PROBE_ID);
|
|
expect(stillToggled!.enabled).toBe(!wasEnabled);
|
|
|
|
// Restore original state for downstream determinism.
|
|
await httpInvoke(page, 'PATCH', `/api/assistants/${BUILTIN_PROBE_ID}/state`, {
|
|
id: BUILTIN_PROBE_ID,
|
|
enabled: wasEnabled,
|
|
});
|
|
});
|
|
|
|
// ── Scenarios 8-10 (migration) run against a sibling backend process.
|
|
// These do not share state with the Electron fixture. See report §Scope. ─
|
|
|
|
test.describe('Migration contract (sibling backend)', () => {
|
|
let backend: ChildProcess | null = null;
|
|
let dataDir: string = '';
|
|
|
|
const baseUrl = `http://127.0.0.1:${MIGRATION_BACKEND_PORT}`;
|
|
|
|
async function waitForHealthy(): Promise<void> {
|
|
const deadline = Date.now() + 10_000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
// eslint-disable-next-line no-await-in-loop -- sequential polling by design
|
|
const r = await fetch(`${baseUrl}/api/system/info`);
|
|
if (r.ok) return;
|
|
} catch {
|
|
// keep polling
|
|
}
|
|
// eslint-disable-next-line no-await-in-loop -- sequential polling by design
|
|
await new Promise((res) => setTimeout(res, 250));
|
|
}
|
|
throw new Error('Sibling backend did not become healthy in 10s');
|
|
}
|
|
|
|
async function httpJson<T>(method: string, route: string, body?: unknown): Promise<T> {
|
|
const res = await fetch(`${baseUrl}${route}`, {
|
|
method,
|
|
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
throw new Error(`Sibling backend ${method} ${route} -> ${res.status}: ${text}`);
|
|
}
|
|
const json = (await res.json()) as { success: boolean; data: T };
|
|
return json.data;
|
|
}
|
|
|
|
async function stopBackend(): Promise<void> {
|
|
if (!backend) return;
|
|
const p = backend;
|
|
backend = null;
|
|
p.kill('SIGTERM');
|
|
await new Promise<void>((resolve) => {
|
|
const t = setTimeout(() => {
|
|
p.kill('SIGKILL');
|
|
resolve();
|
|
}, 3_000);
|
|
p.once('exit', () => {
|
|
clearTimeout(t);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function startBackend(): Promise<void> {
|
|
const bin = resolveBackendBinary();
|
|
const logPath = path.join(dataDir, 'sibling-aioncore.log');
|
|
const logFd = fs.openSync(logPath, 'a');
|
|
// Scrub env vars that would drag the main Electron's backend state in.
|
|
const parentEnv = { ...process.env };
|
|
delete parentEnv.AIONUI_EXTENSIONS_PATH;
|
|
delete parentEnv.AIONUI_EXTENSION_STATES_FILE;
|
|
delete parentEnv.AIONUI_E2E_TEST;
|
|
delete parentEnv.AIONUI_CDP_PORT;
|
|
backend = spawn(bin, ['--local', '--port', String(MIGRATION_BACKEND_PORT), '--data-dir', dataDir], {
|
|
stdio: ['ignore', logFd, logFd],
|
|
env: { ...parentEnv, RUST_LOG: 'warn' },
|
|
});
|
|
try {
|
|
await waitForHealthy();
|
|
} catch (err) {
|
|
const tail = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8').slice(-2000) : '(no log)';
|
|
throw new Error(`${(err as Error).message}\n--- sibling backend log tail ---\n${tail}`, { cause: err });
|
|
}
|
|
}
|
|
|
|
test.beforeEach(async () => {
|
|
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aionui-e2e-migrate-'));
|
|
await startBackend();
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
await stopBackend();
|
|
if (dataDir && fs.existsSync(dataDir)) {
|
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ── Scenario 8 — Happy path ────────────────────────────────────────────
|
|
|
|
test('S8: legacy import happy path — 3 user rows land, built-ins filtered', async () => {
|
|
// The Electron migration hook filters built-ins (prefix + whitelist)
|
|
// *before* calling /api/assistants/import. We replay the same
|
|
// filtering that migrateAssistants.ts does.
|
|
const legacyPayload = {
|
|
assistants: [
|
|
{ id: 'custom-s8-alpha', name: 'Alpha' },
|
|
{ id: 'custom-s8-beta', name: 'Beta' },
|
|
{ id: 'custom-s8-gamma', name: 'Gamma' },
|
|
// Built-in rows are filtered by the hook itself, so no payload
|
|
// entry for them here (the hook strips `builtin-*` before import).
|
|
],
|
|
};
|
|
const result = await httpJson<ImportResult>('POST', '/api/assistants/import', legacyPayload);
|
|
expect(result.imported).toBe(3);
|
|
expect(result.failed).toBe(0);
|
|
|
|
const list = await httpJson<Assistant[]>('GET', '/api/assistants');
|
|
const userIds = list
|
|
.filter((a) => a.source === 'user')
|
|
.map((a) => a.id)
|
|
.toSorted();
|
|
expect(userIds).toEqual(['custom-s8-alpha', 'custom-s8-beta', 'custom-s8-gamma']);
|
|
|
|
// SQLite row verification via the sqlite3 CLI (no native bindings required).
|
|
const ids = querySqliteIds(dataDir, 'SELECT id FROM assistants ORDER BY id');
|
|
expect(ids).toEqual(['custom-s8-alpha', 'custom-s8-beta', 'custom-s8-gamma']);
|
|
});
|
|
|
|
// ── Scenario 9 — Retry idempotency ────────────────────────────────────
|
|
|
|
test('S9: retry import is idempotent (skips existing, no duplicates)', async () => {
|
|
const payload = {
|
|
assistants: [
|
|
{ id: 'custom-s9-retry', name: 'Retry' },
|
|
{ id: 'custom-s9-other', name: 'Other' },
|
|
],
|
|
};
|
|
const first = await httpJson<ImportResult>('POST', '/api/assistants/import', payload);
|
|
expect(first.imported).toBe(2);
|
|
expect(first.skipped).toBe(0);
|
|
|
|
const second = await httpJson<ImportResult>('POST', '/api/assistants/import', payload);
|
|
expect(second.imported).toBe(0);
|
|
expect(second.skipped).toBe(2);
|
|
expect(second.failed).toBe(0);
|
|
|
|
// SQLite row count must not grow.
|
|
const ids = querySqliteIds(dataDir, "SELECT id FROM assistants WHERE id LIKE 'custom-s9-%'");
|
|
expect(ids.length).toBe(2);
|
|
});
|
|
|
|
// ── Scenario 10 — Collision rename ────────────────────────────────────
|
|
|
|
test('S10: legacy row with built-in id is either skipped (if hook renames) or rejected', async () => {
|
|
// Contract: /api/assistants/import MUST skip any payload entry whose
|
|
// id matches a known built-in. The frontend migration hook first
|
|
// renames colliding ids to `custom-migrated-*` *before* calling this
|
|
// endpoint (spec §8.1); the renamed row then imports cleanly.
|
|
const collisionPayload = {
|
|
assistants: [
|
|
{ id: BUILTIN_PROBE_ID, name: 'Hijacked' }, // hook would have renamed
|
|
{ id: `custom-migrated-1700000000000-abcd`, name: 'Hijacked' }, // post-rename form
|
|
],
|
|
};
|
|
const result = await httpJson<ImportResult>('POST', '/api/assistants/import', collisionPayload);
|
|
expect(result.imported).toBe(1);
|
|
expect(result.skipped).toBe(1);
|
|
expect(result.failed).toBe(0);
|
|
|
|
const list = await httpJson<Assistant[]>('GET', '/api/assistants');
|
|
const users = list.filter((a) => a.source === 'user');
|
|
expect(users.length).toBe(1);
|
|
expect(users[0].id).toBe('custom-migrated-1700000000000-abcd');
|
|
expect(users[0].name).toBe('Hijacked');
|
|
|
|
// Built-in word-creator must still resolve as built-in (no hijack).
|
|
const builtin = list.find((a) => a.id === BUILTIN_PROBE_ID);
|
|
expect(builtin?.source).toBe('builtin');
|
|
expect(builtin?.name).not.toBe('Hijacked');
|
|
});
|
|
});
|
|
});
|