diff --git a/apps/e2e/package.json b/apps/e2e/package.json index f62c138a..58e40a20 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -13,6 +13,10 @@ "test:smoke:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/smoke.test.ts", "test:schema-history": "vitest run --config vitest.config.ts src/tests/schema-history.test.ts", "test:schema-history:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/schema-history.test.ts", + "test:schema-revert": "vitest run --config vitest.config.ts src/tests/schema-revert.test.ts", + "test:schema-revert:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/schema-revert.test.ts", + "test:schema-revert-edges": "vitest run --config vitest.config.ts src/tests/schema-version-revert-edges.test.ts", + "test:schema-revert-edges:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/schema-version-revert-edges.test.ts", "test:sql-editor": "vitest run --config vitest.config.ts src/tests/sql-editor-smoke.test.ts src/tests/sql-editor-sqlite.test.ts src/tests/sql-editor-blueprint.test.ts src/tests/sql-editor-copy-export.test.ts src/tests/sql-editor-running-state.test.ts src/tests/sql-editor-utilities.test.ts src/tests/sql-editor-column-picker.test.ts src/tests/sql-editor-code-cell-faker.test.ts src/tests/sql-editor-result-edit.test.ts", "test:sql-editor:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/sql-editor-smoke.test.ts src/tests/sql-editor-sqlite.test.ts src/tests/sql-editor-blueprint.test.ts src/tests/sql-editor-copy-export.test.ts src/tests/sql-editor-running-state.test.ts src/tests/sql-editor-utilities.test.ts src/tests/sql-editor-column-picker.test.ts src/tests/sql-editor-code-cell-faker.test.ts src/tests/sql-editor-result-edit.test.ts", "test:sql-editor:result-edit": "vitest run --config vitest.config.ts src/tests/sql-editor-result-edit.test.ts", diff --git a/apps/e2e/scripts/run-all.mjs b/apps/e2e/scripts/run-all.mjs index 8764ff3e..214b4477 100644 --- a/apps/e2e/scripts/run-all.mjs +++ b/apps/e2e/scripts/run-all.mjs @@ -90,6 +90,16 @@ const ALWAYS = [ file: 'src/tests/schema-history.test.ts', label: 'History', }, + { + key: 'schema-revert', + file: 'src/tests/schema-revert.test.ts', + label: 'Revert', + }, + { + key: 'schema-revert-edges', + file: 'src/tests/schema-version-revert-edges.test.ts', + label: 'Revert edges', + }, ...(configured.length ? [ { @@ -112,7 +122,9 @@ for (const suite of ALWAYS) { const suiteTimeoutMs = suite.key === 'sql-editor-utilities' ? Math.max(600_000, configured.length * 60_000) - : 300_000; + : suite.key === 'schema-revert-edges' + ? 600_000 + : 300_000; try { output = execSync(`${HEADED}${VITEST} ${suite.file}`, { cwd: ROOT, diff --git a/apps/e2e/src/pages/LokeeHistoryPage.ts b/apps/e2e/src/pages/LokeeHistoryPage.ts index 4131e32b..c1d49589 100644 --- a/apps/e2e/src/pages/LokeeHistoryPage.ts +++ b/apps/e2e/src/pages/LokeeHistoryPage.ts @@ -174,15 +174,44 @@ export class LokeeHistoryPage { /** Pick the Original side by its visible label (e.g. "Version 1"). */ async selectOriginalVersion(label: string): Promise { - const select = this.page.locator('[data-testid="lokee-original-version"]'); + await this.selectVersionOption('lokee-original-version', label); + } + + /** Pick Target by label. Use `selectTargetCurrent()` for the latest snapshot. */ + async selectTargetVersion(label: string): Promise { + await this.selectVersionOption('lokee-target-version', label); + } + + async selectTargetCurrent(): Promise { + const select = this.page.locator('[data-testid="lokee-target-version"]'); + await select.waitFor({ state: 'visible', timeout: 20_000 }); + await select.selectOption(''); + } + + private async selectVersionOption(testId: string, label: string): Promise { + const select = this.page.locator(`[data-testid="${testId}"]`); await select.waitFor({ state: 'visible', timeout: 20_000 }); const option = select.locator('option', { hasText: label }); await option.waitFor({ state: 'attached', timeout: 20_000 }); const value = await option.getAttribute('value'); - if (!value) throw new Error(`No Original version option matching ${label}`); + if (value == null) throw new Error(`No ${testId} option matching ${label}`); await select.selectOption(value); } + async originalVersionLabels(): Promise { + const select = this.page.locator('[data-testid="lokee-original-version"]'); + await select.waitFor({ state: 'visible', timeout: 20_000 }); + return select.locator('option').allTextContents(); + } + + async graphVersionNodeCount(): Promise { + return this.page.locator('[data-testid^="rf-version-"]').count(); + } + + async compareVersionsButtonVisible(): Promise { + return this.page.locator('[data-testid="lokee-compare-versions-btn"]').isVisible(); + } + async openCompareModal(): Promise { await clickWhen(this.page, '[data-testid="lokee-compare-versions-btn"]'); await this.page.waitForSelector('[data-testid="lokee-version-compare"][data-state="ready"]', { @@ -239,4 +268,79 @@ export class LokeeHistoryPage { async compareModalOpen(): Promise { return this.page.locator('[data-testid="lokee-version-compare"]').isVisible(); } + + async closeCompareModal(): Promise { + if (!(await this.compareModalOpen())) return; + await clickWhen(this.page, '[data-testid="lokee-version-compare-close"]'); + await this.page.waitForSelector('[data-testid="lokee-version-compare"]', { + state: 'detached', + timeout: 15_000, + }); + } + + async compareSummaryText(): Promise { + const el = this.page.locator('[data-testid="lokee-cmp-summary"]'); + await el.waitFor({ state: 'visible', timeout: 20_000 }); + return (await el.innerText()) ?? ''; + } + + async compareIdenticalVisible(): Promise { + return this.page.locator('[data-testid="lokee-cmp-identical"]').isVisible(); + } + + async runRevertButtonText(): Promise { + const run = this.page.locator('[data-testid="lokee-cmp-run-revert"]'); + await run.waitFor({ state: 'visible', timeout: 20_000 }); + return (await run.innerText()) ?? ''; + } + + async runRevertDisabled(): Promise { + const run = this.page.locator('[data-testid="lokee-cmp-run-revert"]'); + await run.waitFor({ state: 'visible', timeout: 20_000 }); + return run.isDisabled(); + } + + /** Tick/untick a changed object in the shared SchemaDiffTree. */ + async toggleCompareObject(tableName: string): Promise { + const row = this.page.locator(`[data-testid="diff-item"][data-object="${tableName}"]`); + await row.waitFor({ state: 'visible', timeout: 20_000 }); + const box = row.locator('input[type="checkbox"]'); + await box.waitFor({ state: 'visible', timeout: 10_000 }); + await box.click(); + } + + async compareObjectNames(): Promise { + const items = this.page.locator('[data-testid="diff-item"]'); + await items.first().waitFor({ state: 'visible', timeout: 20_000 }); + const count = await items.count(); + const names: string[] = []; + for (let i = 0; i < count; i++) { + names.push((await items.nth(i).getAttribute('data-object')) ?? ''); + } + return names.filter(Boolean); + } + + async captureFromHistoryBar(): Promise { + const btn = this.page.locator('[data-testid="lokee-capture-btn"]'); + await btn.waitFor({ state: 'visible', timeout: 15_000 }); + await this.page.waitForFunction( + () => { + const el = document.querySelector('[data-testid="lokee-capture-btn"]'); + return el instanceof HTMLButtonElement && !el.disabled; + }, + { timeout: 15_000 } + ); + await btn.click(); + await this.page.waitForFunction( + () => { + const el = document.querySelector('[data-testid="lokee-capture-btn"]'); + return ( + el instanceof HTMLButtonElement && + !el.disabled && + /(Capture|Capturing)/i.test(el.textContent ?? '') + ); + }, + { timeout: 30_000 } + ); + } } diff --git a/apps/e2e/src/tests/schema-version-revert-edges.test.ts b/apps/e2e/src/tests/schema-version-revert-edges.test.ts new file mode 100644 index 00000000..c57a9fd0 --- /dev/null +++ b/apps/e2e/src/tests/schema-version-revert-edges.test.ts @@ -0,0 +1,448 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Edge cases for History versioning and revert. The happy path lives in + * schema-history.test.ts (snapshot + inspector) and schema-revert.test.ts + * (drop index → compare → execute → file has the index back). + * + * These cases pin the contracts Claude already wrote down: + * - an unchanged capture reuses the hash pointer (no extra version) + * - Original / Target are versions; the graph does not follow the pickers + * - Compare versions is a preview — opening it must not touch the live file + * - empty ticks are a no-op, not "revert everything" + * - a scoped tick reverts only that object + * - a revert records a *new* version; it never rewrites the one it targets + * - lossy plans require the Migration SQL acknowledgement + * - the file on disk is the source of truth, not the toast + * + * Requires `npm run dev` and `sqlite3`. Skips when sqlite3 is missing. + * Intended for Claude to run and fix — do not assume this file is green. + */ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { execFileSync, execSync } from 'node:child_process'; +import { mkdirSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Page } from 'playwright'; +import { buildDriver, quitDriver } from '../helpers/driver.js'; +import { AppPage } from '../pages/AppPage.js'; +import { SqlEditorPage } from '../pages/SqlEditorPage.js'; +import { LokeeHistoryPage } from '../pages/LokeeHistoryPage.js'; + +function hasSqlite3(): boolean { + try { + execSync('which sqlite3', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +const ready = hasSqlite3(); + +function sqliteAt(path: string, input: string): string { + return execFileSync('sqlite3', [path], { input, encoding: 'utf8' }); +} + +function schemaAt(path: string): string { + return execFileSync('sqlite3', [path, '.schema'], { encoding: 'utf8' }); +} + +async function boot( + dbPath: string, + name: string +): Promise<{ driver: Page; history: LokeeHistoryPage; sql: SqlEditorPage }> { + const driver = await buildDriver(); + const app = new AppPage(driver); + const sql = new SqlEditorPage(driver); + const history = new LokeeHistoryPage(driver); + await app.open(); + await sql.resetPersistedEditorState(); + await driver.reload(); + await driver.waitForSelector('[data-testid="toolbar"]'); + await sql.addSqliteCredential(name, dbPath); + await driver.locator('[data-testid="view-sync-btn"]').click(); + await driver.waitForSelector('[data-testid="sync-pane-switcher"]'); + await history.selectSavedTargetByName(name); + return { driver, history, sql }; +} + +// ── 1. Versioning: capture, pickers, preview ──────────────────────────────── + +describe.skipIf(!ready)('History · versioning edge cases (SQLite)', () => { + const RUN = Date.now().toString(36); + const DIR = `/tmp/foxschema-e2e-version-edges-${RUN}`; + const DB = join(DIR, 'versions.db'); + const NAME = `E2E Versions ${RUN}`; + + let driver: Page; + let history: LokeeHistoryPage; + + beforeAll(async () => { + rmSync(DIR, { recursive: true, force: true }); + mkdirSync(DIR, { recursive: true }); + sqliteAt( + DB, + ` +CREATE TABLE customers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT +); +CREATE INDEX idx_customers_email ON customers(email); +CREATE TABLE invoices ( + id INTEGER PRIMARY KEY, + total INTEGER NOT NULL +); +INSERT INTO customers (id, name, email) VALUES (1, 'Ada', 'ada@example.com'); +INSERT INTO invoices (id, total) VALUES (1, 10); +` + ); + expect(existsSync(DB)).toBe(true); + ({ driver, history } = await boot(DB, NAME)); + }, 120_000); + + afterAll(async () => { + if (driver) await quitDriver(driver); + rmSync(DIR, { recursive: true, force: true }); + }); + + it('does not invent a second version when the schema did not change', async () => { + await history.snapshotTarget(); + await history.openHistoryPane(); + await history.selectHistoryDatabaseContaining(RUN); + await history.waitForGraph(); + await expect.poll(async () => history.versionCount(), { timeout: 30_000 }).toBe(1); + + // One version ⇒ Original and Target resolve to the same id ⇒ no pair to compare. + expect(await history.compareVersionsButtonVisible()).toBe(false); + + await history.captureFromHistoryBar(); + await history.waitForGraph(); + await expect.poll(async () => history.versionCount(), { timeout: 20_000 }).toBe(1); + }, 120_000); + + it('records a real change as v2 and keeps every version node on the graph', async () => { + sqliteAt(DB, 'ALTER TABLE customers ADD COLUMN phone TEXT;\n'); + sqliteAt(DB, 'ALTER TABLE invoices ADD COLUMN note TEXT;\n'); + sqliteAt(DB, 'DROP INDEX idx_customers_email;\n'); + expect(schemaAt(DB)).toMatch(/phone/i); + expect(schemaAt(DB)).toMatch(/note/i); + expect(schemaAt(DB)).not.toMatch(/idx_customers_email/i); + + await history.captureFromHistoryBar(); + await history.waitForGraph(); + await expect.poll(async () => history.versionCount(), { timeout: 30_000 }).toBe(2); + + const nodesBeforePick = await history.graphVersionNodeCount(); + expect(nodesBeforePick).toBeGreaterThanOrEqual(2); + + // Pickers choose what to compare. They must not hide the other version — + // that was the #261 regression: choosing Version 1 collapsed the timeline. + await history.selectOriginalVersion('Version 1'); + await history.selectTargetCurrent(); + await expect.poll(async () => history.graphVersionNodeCount(), { timeout: 10_000 }).toBe( + nodesBeforePick + ); + expect(await history.compareVersionsButtonVisible()).toBe(true); + }, 120_000); + + it('hides Compare versions when Original and Target are the same version', async () => { + await history.selectOriginalVersion('Version 2'); + await history.selectTargetCurrent(); + expect(await history.compareVersionsButtonVisible()).toBe(false); + + await history.selectOriginalVersion('Version 1'); + await history.selectTargetCurrent(); + expect(await history.compareVersionsButtonVisible()).toBe(true); + }, 60_000); + + it('opens a version compare without writing the live database', async () => { + const before = schemaAt(DB); + await history.selectOriginalVersion('Version 1'); + await history.selectTargetCurrent(); + await history.openCompareModal(); + + const summary = await history.compareSummaryText(); + expect(summary, summary).toMatch(/added|modified|removed/i); + const names = await history.compareObjectNames(); + expect(names.some((n) => /customers/i.test(n))).toBe(true); + + const migration = await history.migrationSqlText(); + // Preview of "make Target match Original" — restore the index, drop the new columns. + expect(migration, migration).toMatch(/idx_customers_email/i); + expect(migration, migration).not.toMatch(/IDX_CUSTOMERS_EMAIL/); + + expect(schemaAt(DB)).toBe(before); + await history.closeCompareModal(); + expect(await history.compareModalOpen()).toBe(false); + }, 120_000); + + it('can compare two historical versions while live stays on the latest schema', async () => { + // Target = Version 1 (older), Original = Version 2. Live file is still v2. + await history.selectOriginalVersion('Version 2'); + await history.selectTargetVersion('Version 1'); + expect(await history.compareVersionsButtonVisible()).toBe(true); + + const before = schemaAt(DB); + await history.openCompareModal(); + expect(await history.compareSummaryText()).toMatch(/added|modified|removed/i); + expect(schemaAt(DB)).toBe(before); + expect(schemaAt(DB)).toMatch(/phone/i); + await history.closeCompareModal(); + }, 120_000); +}); + +// ── 2. Revert: no-op ticks, scoped object, new version ─────────────────────── + +describe.skipIf(!ready)('History · revert scope edge cases (SQLite)', () => { + const RUN = Date.now().toString(36); + const DIR = `/tmp/foxschema-e2e-revert-scope-${RUN}`; + const DB = join(DIR, 'scope.db'); + const NAME = `E2E Revert Scope ${RUN}`; + + let driver: Page; + let history: LokeeHistoryPage; + + beforeAll(async () => { + rmSync(DIR, { recursive: true, force: true }); + mkdirSync(DIR, { recursive: true }); + sqliteAt( + DB, + ` +CREATE TABLE customers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL +); +CREATE TABLE invoices ( + id INTEGER PRIMARY KEY, + total INTEGER NOT NULL +); +INSERT INTO customers (id, name) VALUES (1, 'Ada'); +INSERT INTO invoices (id, total) VALUES (1, 10); +` + ); + ({ driver, history } = await boot(DB, NAME)); + await history.snapshotTarget(); + sqliteAt(DB, 'ALTER TABLE customers ADD COLUMN phone TEXT;\n'); + sqliteAt(DB, 'ALTER TABLE invoices ADD COLUMN note TEXT;\n'); + await history.snapshotTarget(); + await history.openHistoryPane(); + await history.selectHistoryDatabaseContaining(RUN); + await history.waitForGraph(); + await expect.poll(async () => history.versionCount(), { timeout: 30_000 }).toBe(2); + }, 180_000); + + afterAll(async () => { + if (driver) await quitDriver(driver); + rmSync(DIR, { recursive: true, force: true }); + }); + + it('does not revert the live schema when no objects are ticked', async () => { + // Backend contract: objectKeys=[] is a no-op. The modal must not send + // `undefined` (whole schema) when the tree has zero ticks. + await history.selectOriginalVersion('Version 1'); + await history.selectTargetCurrent(); + await history.openCompareModal(); + const beforeSchema = schemaAt(DB); + const beforeVersions = await history.versionCount(); + + if (await history.runRevertDisabled()) { + expect(schemaAt(DB)).toBe(beforeSchema); + } else { + await history.executeRevert(); + await history.closeCompareModal().catch(() => undefined); + } + + expect(schemaAt(DB), schemaAt(DB)).toMatch(/phone/i); + expect(schemaAt(DB), schemaAt(DB)).toMatch(/note/i); + expect(schemaAt(DB)).toBe(beforeSchema); + expect(await history.versionCount()).toBe(beforeVersions); + await history.closeCompareModal(); + }, 180_000); + + it('reverts only the ticked object and leaves the other table on the later schema', async () => { + await history.selectOriginalVersion('Version 1'); + await history.selectTargetCurrent(); + await history.openCompareModal(); + const names = await history.compareObjectNames(); + const invoices = names.find((n) => /invoices/i.test(n)); + expect(invoices, `tree objects: ${names.join(', ')}`).toBeTruthy(); + await history.toggleCompareObject(invoices!); + + const calls: string[] = []; + driver.on('response', (res) => { + const url = res.url(); + if (url.includes('/lokee/') && url.includes('/revert') && res.request().method() === 'POST') { + calls.push(`${res.status()} ${url}`); + } + }); + + await history.executeRevert(); + try { + await expect.poll(() => schemaAt(DB), { timeout: 30_000 }).not.toMatch(/note/i); + } catch (error) { + throw new Error( + `Scoped revert did not drop invoices.note.\nPOSTs: ${calls.join(' :: ') || '(none)'}\nToast: ${await history.toastText()}\nSchema: ${schemaAt(DB)}`, + { cause: error } + ); + } + + // customers.phone is the unticked side — must still be on the live file. + expect(schemaAt(DB), schemaAt(DB)).toMatch(/phone/i); + expect(sqliteAt(DB, 'SELECT count(*) FROM customers;\n').trim()).toBe('1'); + expect(sqliteAt(DB, 'SELECT count(*) FROM invoices;\n').trim()).toBe('1'); + + await driver.waitForSelector('[data-testid="lokee-version-compare"]', { + state: 'detached', + timeout: 30_000, + }); + await expect.poll(async () => history.versionCount(), { timeout: 30_000 }).toBe(3); + }, 180_000); +}); + +// ── 3. Revert: lossy ack, drop-table, pendulum ─────────────────────────────── + +describe.skipIf(!ready)('History · revert lossy and pendulum (SQLite)', () => { + const RUN = Date.now().toString(36); + const DIR = `/tmp/foxschema-e2e-revert-lossy-${RUN}`; + const DB = join(DIR, 'lossy.db'); + const NAME = `E2E Revert Lossy ${RUN}`; + + let driver: Page; + let history: LokeeHistoryPage; + + beforeAll(async () => { + rmSync(DIR, { recursive: true, force: true }); + mkdirSync(DIR, { recursive: true }); + sqliteAt( + DB, + ` +CREATE TABLE customers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT +); +CREATE INDEX idx_customers_email ON customers(email); +INSERT INTO customers (id, name, email) VALUES (1, 'Ada', 'ada@example.com'); +` + ); + ({ driver, history } = await boot(DB, NAME)); + await history.snapshotTarget(); + sqliteAt(DB, 'ALTER TABLE customers ADD COLUMN phone TEXT;\n'); + sqliteAt(DB, "UPDATE customers SET phone = '555' WHERE id = 1;\n"); + sqliteAt( + DB, + ` +CREATE TABLE audit ( + id INTEGER PRIMARY KEY, + body TEXT +); +INSERT INTO audit (id, body) VALUES (1, 'gone on revert'); +` + ); + sqliteAt(DB, 'DROP INDEX idx_customers_email;\n'); + await history.snapshotTarget(); + await history.openHistoryPane(); + await history.selectHistoryDatabaseContaining(RUN); + await history.waitForGraph(); + await expect.poll(async () => history.versionCount(), { timeout: 30_000 }).toBe(2); + }, 180_000); + + afterAll(async () => { + if (driver) await quitDriver(driver); + rmSync(DIR, { recursive: true, force: true }); + }); + + it('refuses to apply a lossy revert until Migration SQL is acknowledged', async () => { + await history.selectOriginalVersion('Version 1'); + await history.selectTargetCurrent(); + await history.openCompareModal(); + + const run = driver.locator('[data-testid="lokee-cmp-run-revert"]'); + await run.waitFor({ state: 'visible', timeout: 20_000 }); + const label = (await run.innerText()) ?? ''; + // Additive-looking CREATE INDEX is mixed with DROP COLUMN / DROP TABLE. + expect(label, label).toMatch(/Review data loss|Execute migration/i); + + if (label.includes('Review data loss')) { + await run.click(); + await driver.locator('[data-testid="lokee-cmp-tab-SQL"]').waitFor({ state: 'visible', timeout: 10_000 }); + expect(await driver.locator('[data-testid="lokee-cmp-confirm-lossy"]').isVisible()).toBe(true); + // Closing without the checkbox must leave the file on v2. + expect(schemaAt(DB)).toMatch(/phone/i); + expect(schemaAt(DB)).toMatch(/audit/i); + await history.closeCompareModal(); + expect(schemaAt(DB)).toMatch(/phone/i); + } else { + // If the chip is not lossy, still require the plan to mention the drops. + const sql = await history.migrationSqlText(); + expect(sql, sql).toMatch(/phone|audit|DROP/i); + await history.closeCompareModal(); + } + }, 180_000); + + it('applies a lossy revert, keeps surviving rows, and records a new version', async () => { + await history.selectOriginalVersion('Version 1'); + await history.selectTargetCurrent(); + await history.openCompareModal(); + + const calls: string[] = []; + driver.on('response', (res) => { + const url = res.url(); + if (url.includes('/lokee/') && url.includes('/revert') && res.request().method() === 'POST') { + calls.push(`${res.status()} ${url}`); + } + }); + + await history.executeRevert(); + try { + await expect.poll(() => schemaAt(DB), { timeout: 45_000 }).not.toMatch(/phone/i); + } catch (error) { + throw new Error( + `Lossy revert did not drop phone.\nPOSTs: ${calls.join(' :: ') || '(none)'}\nToast: ${await history.toastText()}\nSchema: ${schemaAt(DB)}`, + { cause: error } + ); + } + + expect(schemaAt(DB), schemaAt(DB)).not.toMatch(/CREATE TABLE audit/i); + expect(schemaAt(DB), schemaAt(DB)).toMatch(/idx_customers_email/i); + expect(sqliteAt(DB, 'SELECT count(*) FROM customers;\n').trim()).toBe('1'); + expect(sqliteAt(DB, 'SELECT name FROM customers WHERE id = 1;\n').trim()).toBe('Ada'); + + await driver.waitForSelector('[data-testid="lokee-version-compare"]', { + state: 'detached', + timeout: 30_000, + }); + // v1 baseline, v2 mutated, v3 revert — never rewrite v1. + await expect.poll(async () => history.versionCount(), { timeout: 30_000 }).toBeGreaterThanOrEqual(3); + }, 180_000); + + it('can revert again to the middle version (pendulum) and still append history', async () => { + const versionsBefore = await history.versionCount(); + await history.selectOriginalVersion('Version 2'); + await history.selectTargetCurrent(); + await history.openCompareModal(); + await history.executeRevert(); + + try { + await expect.poll(() => schemaAt(DB), { timeout: 45_000 }).toMatch(/phone/i); + } catch (error) { + throw new Error( + `Pendulum revert did not restore the v2 schema.\nToast: ${await history.toastText()}\nSchema: ${schemaAt(DB)}`, + { cause: error } + ); + } + + await driver.waitForSelector('[data-testid="lokee-version-compare"]', { + state: 'detached', + timeout: 30_000, + }); + await expect + .poll(async () => history.versionCount(), { timeout: 30_000 }) + .toBeGreaterThan(versionsBefore); + // Pendulum must not delete Ada while recreating phone / audit. + expect(sqliteAt(DB, 'SELECT count(*) FROM customers;\n').trim()).toBe('1'); + }, 180_000); +});