Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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