Skip to content
39 changes: 37 additions & 2 deletions apps/e2e/src/pages/LokeeHistoryPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ export class LokeeHistoryPage {
await this.page.waitForFunction(
(expected) => {
const el = document.querySelector('[data-testid="lokee-summary"]');
const text = el?.textContent ?? '';
// innerText, not textContent: the summary is a row of sibling spans, so
// textContent runs them together as "…2 versions10 objects…" and the
// trailing \b never matches — `s` meets `1`, both word characters.
const text = (el as HTMLElement | null)?.innerText ?? '';
return new RegExp(`\\b${expected}\\s+versions?\\b`, 'i').test(text);
},
n,
Expand Down Expand Up @@ -309,9 +312,30 @@ export class LokeeHistoryPage {
await box.click();
}

/**
* Tick every changed object. Required before Execute: an empty tick set is
* refused rather than silently widening to the whole schema, which is what
* used to revert an entire database from a dialog with nothing selected.
*/
async selectAllCompareObjects(): Promise<void> {
await clickWhen(this.page, '[data-testid="lokee-cmp-select-all"]');
}

async compareObjectNames(): Promise<string[]> {
const items = this.page.locator('[data-testid="diff-item"]');
await items.first().waitFor({ state: 'visible', timeout: 20_000 });
try {
await items.first().waitFor({ state: 'visible', timeout: 20_000 });
} catch (error) {
// "no diff rows" and "the dialog says the versions are identical" look
// the same from a timeout, and they have opposite causes.
const open = await this.compareModalOpen();
const identical = await this.compareIdenticalVisible().catch(() => false);
const summary = await this.compareSummaryText().catch(() => '');
throw new Error(
`No objects in the compare tree. modalOpen=${open} identical=${identical} summary=${summary}`,
{ cause: error }
);
}
const count = await items.count();
const names: string[] = [];
for (let i = 0; i < count; i++) {
Expand Down Expand Up @@ -343,4 +367,15 @@ export class LokeeHistoryPage {
{ timeout: 30_000 }
);
}

/** "↩ reverted to vN" labels on the version nodes, newest first. */
async revertedToLabels(): Promise<string[]> {
const marks = this.page.locator('[data-testid^="rf-version-revert-"]');
const count = await marks.count();
const out: string[] = [];
for (let i = 0; i < count; i++) {
out.push((await marks.nth(i).innerText().catch(() => '')) ?? '');
}
return out;
}
}
140 changes: 140 additions & 0 deletions apps/e2e/src/tests/schema-browse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* Schema Sync → Browse, against a local SQLite file.
*
* Browse used to be a mode hiding inside Compare, reachable only by pressing a
* button on one of Compare's two connection cards. It is its own pane now, so
* this covers the parts that only exist there: the search box and type filters
* on the left, and the connection card on the right naming the one database
* being read.
*
* Requires `npm run dev` and `sqlite3` on PATH. Skips when sqlite3 is missing.
*/
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';

const RUN = Date.now().toString(36);
const DIR = `/tmp/foxschema-e2e-schema-browse-${RUN}`;
const DB = join(DIR, 'browse.db');
const NAME = `E2E Browse ${RUN}`;

function hasSqlite3(): boolean {
try {
execSync('which sqlite3', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}

const ready = hasSqlite3();

describe.skipIf(!ready)('Schema Sync · Browse (SQLite)', () => {
let driver: Page;
let app: AppPage;
let sql: SqlEditorPage;

beforeAll(async () => {
rmSync(DIR, { recursive: true, force: true });
mkdirSync(DIR, { recursive: true });
execFileSync('sqlite3', [DB], {
input: `
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total REAL);
CREATE INDEX idx_customers_email ON customers(email);
CREATE VIEW v_customers AS SELECT id, name FROM customers;
`,
});
expect(existsSync(DB)).toBe(true);

driver = await buildDriver();
app = new AppPage(driver);
sql = new SqlEditorPage(driver);

await app.open();
await sql.resetPersistedEditorState();
await driver.reload();
await driver.waitForSelector('[data-testid="toolbar"]');
await sql.addSqliteCredential(NAME, DB);
await driver.locator('[data-testid="view-sync-btn"]').click();
await driver.waitForSelector('[data-testid="sync-pane-switcher"]');
}, 120_000);

afterAll(async () => {
if (driver) await quitDriver(driver);
rmSync(DIR, { recursive: true, force: true });
});

it('offers Browse as its own pane beside Compare and History', async () => {
expect(await driver.locator('[data-testid="sync-pane-compare-btn"]').isVisible()).toBe(true);
expect(await driver.locator('[data-testid="sync-pane-browse-btn"]').isVisible()).toBe(true);
expect(await driver.locator('[data-testid="sync-pane-history-btn"]').isVisible()).toBe(true);
});

it('says what it wants before anything is loaded', async () => {
await driver.locator('[data-testid="sync-pane-browse-btn"]').click();
const empty = driver.locator('[data-testid="schema-tree-empty"]');
await empty.waitFor({ state: 'visible', timeout: 15_000 });
// Not "No Comparison Active" — there is no comparison in this pane.
expect(await empty.innerText()).toMatch(/Nothing loaded/i);
});

it('reads one database from its own one-connection bar', async () => {
// Browse has no Original/Target and no swap — picking the database is the
// whole interaction, and it loads on pick.
// Browse's own bar, with no Original/Target and no swap. Compare keeps its
// two-sided grid and its own Browse buttons — untouched by this pane.
expect(await driver.locator('[data-testid="browse-bar"]').isVisible()).toBe(true);
expect(await driver.locator('[data-testid="source-saved-select"]').count()).toBe(0);

const select = driver.locator('[data-testid="browse-connection-select"]');
const option = select.locator('option', { hasText: NAME });
await option.waitFor({ state: 'attached', timeout: 15_000 });
await select.selectOption((await option.getAttribute('value'))!);

await driver.waitForSelector('[data-testid="browse-type-filter"]', { timeout: 30_000 });

// Left: the search box and the type filters, next to the list they filter.
expect(await driver.locator('input[placeholder*="Search objects"]').isVisible()).toBe(true);
expect(await driver.locator('[data-testid="browse-type-TABLE"]').isVisible()).toBe(true);
expect(await driver.locator('[data-testid="browse-type-VIEW"]').isVisible()).toBe(true);
}, 120_000);

it('narrows the tree by type', async () => {
await driver.locator('[data-testid="browse-type-VIEW"]').click();
await expect
.poll(async () => driver.locator('[data-testid="diff-item"]').count(), { timeout: 15_000 })
.toBe(1);
expect(await driver.locator('[data-testid="diff-item"]').innerText()).toMatch(/v_customers/i);

// Back to everything.
await driver.locator('[data-testid="browse-type-VIEW"]').click();
await expect
.poll(async () => driver.locator('[data-testid="diff-item"]').count(), { timeout: 15_000 })
.toBeGreaterThan(1);
}, 120_000);

it('names the database being browsed on the right', async () => {
// Compare names two connections in the toolbar; Browse has one, and before
// this it was nowhere on screen.
const card = driver.locator('[data-testid="browse-connection-card"]');
if (!(await card.isVisible().catch(() => false))) {
// A row is selected by default, so clear the selection to reach the
// empty-detail state that carries the card.
await driver.locator('input[placeholder*="Search objects"]').fill('zzz-no-such-object');
await card.waitFor({ state: 'visible', timeout: 15_000 });
}
const text = await card.innerText();
expect(text).toMatch(/SQLITE/i);
expect(text, text).toMatch(/browse\.db/i);
}, 120_000);
});
37 changes: 37 additions & 0 deletions apps/e2e/src/tests/schema-revert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,41 @@ INSERT INTO customers (id, name, email) VALUES (1, 'Ada', 'ada@example.com');
const rows = sqlite('SELECT count(*) FROM customers;\n').trim();
expect(rows).toBe('1');
}, 180_000);

it('reverts forward again, and logs which version each revert restored', async () => {
// Backward was proven above (v2 → v1). Forward is the same machinery with
// the sides the other way round: pick the *newer* version as Original and
// the live database moves up to it. A revert is just a migration to a
// stored state, so "forward" and "backward" must not be different code.
await history.selectHistoryDatabaseContaining(RUN);
await history.waitForGraph();

// v3 (the revert) restored v1, so the index is present. Reverting to v2 —
// the version that dropped it — moves forward and drops it again.
await history.selectOriginalVersion('Version 2');
await history.openCompareModal();
const forward = await history.migrationSqlText();
expect(forward, forward).toMatch(/DROP INDEX/i);

await history.executeRevert();
await expect
.poll(() => schemaText(), { timeout: 30_000 })
.not.toMatch(/idx_customers_email/i);

await driver.waitForSelector('[data-testid="lokee-version-compare"]', {
state: 'detached',
timeout: 30_000,
});
await expect
.poll(async () => history.versionCount(), { timeout: 30_000 })
.toBeGreaterThanOrEqual(4);

// Both reverts are legible from the graph: each node says which version it
// put back, which is the whole point of recording the provenance.
await history.waitForGraph();
const restored = await history.revertedToLabels();
expect(restored.join(' '), restored.join(' ')).toMatch(/reverted to v1/i);
expect(restored.join(' '), restored.join(' ')).toMatch(/reverted to v2/i);
}, 180_000);
});

47 changes: 42 additions & 5 deletions apps/e2e/src/tests/schema-version-revert-edges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ async function boot(
// ── 1. Versioning: capture, pickers, preview ────────────────────────────────

describe.skipIf(!ready)('History · versioning edge cases (SQLite)', () => {
const RUN = Date.now().toString(36);
// Suffixed per suite: all three describes evaluate in the same
// millisecond, so a bare timestamp is not unique and the history
// picker matched more than one database.
const RUN = `${Date.now().toString(36)}-edges`;
const DIR = `/tmp/foxschema-e2e-version-edges-${RUN}`;
const DB = join(DIR, 'versions.db');
const NAME = `E2E Versions ${RUN}`;
Expand Down Expand Up @@ -197,7 +200,10 @@ INSERT INTO invoices (id, total) VALUES (1, 10);
// ── 2. Revert: no-op ticks, scoped object, new version ───────────────────────

describe.skipIf(!ready)('History · revert scope edge cases (SQLite)', () => {
const RUN = Date.now().toString(36);
// Suffixed per suite: all three describes evaluate in the same
// millisecond, so a bare timestamp is not unique and the history
// picker matched more than one database.
const RUN = `${Date.now().toString(36)}-scope`;
const DIR = `/tmp/foxschema-e2e-revert-scope-${RUN}`;
const DB = join(DIR, 'scope.db');
const NAME = `E2E Revert Scope ${RUN}`;
Expand Down Expand Up @@ -231,7 +237,19 @@ INSERT INTO invoices (id, total) VALUES (1, 10);
await history.openHistoryPane();
await history.selectHistoryDatabaseContaining(RUN);
await history.waitForGraph();
await expect.poll(async () => history.versionCount(), { timeout: 30_000 }).toBe(2);
// `expect.poll` is only legal inside a test — this is a beforeAll hook, so
// wait on the page instead. The page object already owns that wait.
try {
await history.waitForVersionCount(2);
} catch (error) {
// A bare timeout here says "setup failed" and nothing else; the summary
// line names the database actually on screen, which is the difference
// between a missed capture and the wrong database being selected.
throw new Error(
`Setup never reached 2 versions.\nSummary: ${await history.summaryText()}`,
{ cause: error }
);
}
}, 180_000);

afterAll(async () => {
Expand Down Expand Up @@ -305,7 +323,10 @@ INSERT INTO invoices (id, total) VALUES (1, 10);
// ── 3. Revert: lossy ack, drop-table, pendulum ───────────────────────────────

describe.skipIf(!ready)('History · revert lossy and pendulum (SQLite)', () => {
const RUN = Date.now().toString(36);
// Suffixed per suite: all three describes evaluate in the same
// millisecond, so a bare timestamp is not unique and the history
// picker matched more than one database.
const RUN = `${Date.now().toString(36)}-lossy`;
const DIR = `/tmp/foxschema-e2e-revert-lossy-${RUN}`;
const DB = join(DIR, 'lossy.db');
const NAME = `E2E Revert Lossy ${RUN}`;
Expand Down Expand Up @@ -347,7 +368,19 @@ INSERT INTO audit (id, body) VALUES (1, 'gone on revert');
await history.openHistoryPane();
await history.selectHistoryDatabaseContaining(RUN);
await history.waitForGraph();
await expect.poll(async () => history.versionCount(), { timeout: 30_000 }).toBe(2);
// `expect.poll` is only legal inside a test — this is a beforeAll hook, so
// wait on the page instead. The page object already owns that wait.
try {
await history.waitForVersionCount(2);
} catch (error) {
// A bare timeout here says "setup failed" and nothing else; the summary
// line names the database actually on screen, which is the difference
// between a missed capture and the wrong database being selected.
throw new Error(
`Setup never reached 2 versions.\nSummary: ${await history.summaryText()}`,
{ cause: error }
);
}
}, 180_000);

afterAll(async () => {
Expand All @@ -359,6 +392,8 @@ INSERT INTO audit (id, body) VALUES (1, 'gone on revert');
await history.selectOriginalVersion('Version 1');
await history.selectTargetCurrent();
await history.openCompareModal();
// Zero ticks is refused by design, so say "all of it" explicitly.
await history.selectAllCompareObjects();

const run = driver.locator('[data-testid="lokee-cmp-run-revert"]');
await run.waitFor({ state: 'visible', timeout: 20_000 });
Expand Down Expand Up @@ -387,6 +422,7 @@ INSERT INTO audit (id, body) VALUES (1, 'gone on revert');
await history.selectOriginalVersion('Version 1');
await history.selectTargetCurrent();
await history.openCompareModal();
await history.selectAllCompareObjects();

const calls: string[] = [];
driver.on('response', (res) => {
Expand Down Expand Up @@ -424,6 +460,7 @@ INSERT INTO audit (id, body) VALUES (1, 'gone on revert');
await history.selectOriginalVersion('Version 2');
await history.selectTargetCurrent();
await history.openCompareModal();
await history.selectAllCompareObjects();
await history.executeRevert();

try {
Expand Down
Loading
Loading