Skip to content
Merged
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
18 changes: 9 additions & 9 deletions apps/cli/src/tui/__tests__/ConnectionFormScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { ConnectionFormScreen } from '../screens/ConnectionFormScreen';

// A real (not setTimeout(0)) delay: ink-text-input needs a render tick to commit each
// character to its controlled `value` before Enter fires, or `onSubmit` receives ''
// (confirmed by tracing the actual value ink-text-input delivers). setTimeout(0) alone
// was intermittently too short under full-suite parallel load (flaky, not deterministic).
const wait = (ms = 40) => new Promise((r) => setTimeout(r, ms));
// (confirmed by tracing the actual value ink-text-input delivers). 40ms was
// intermittently too short under full-suite parallel load.
const wait = (ms = 100) => new Promise((r) => setTimeout(r, ms));

/** Types a value then presses Enter, with a tick between so ink-text-input commits it first. */
async function type(stdin: { write: (s: string) => void }, value: string) {
Expand Down Expand Up @@ -38,13 +38,13 @@ describe('ConnectionFormScreen', () => {
await type(stdin, 'foxdb'); // database
await type(stdin, 'foxuser'); // user
await type(stdin, 'demo_c'); // schema
await vi.waitFor(() => expect(lastFrame()).toContain('Password'));
await vi.waitFor(() => expect(lastFrame()).toContain('Password'), { timeout: 10_000 });

await type(stdin, 's3cret'); // password
await vi.waitFor(() => expect(lastFrame()).toContain('Save this connection'));
await vi.waitFor(() => expect(lastFrame()).toContain('Save this connection'), { timeout: 10_000 });

stdin.write('\r'); // first item ("Yes, save it") is pre-selected — enter accepts it
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled());
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled(), { timeout: 10_000 });

expect(ctx.connections.create).toHaveBeenCalledWith(
'u1',
Expand All @@ -53,7 +53,7 @@ describe('ConnectionFormScreen', () => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ dialect: 'postgres', schema: 'demo_c', label: 'demo_c' })
);
});
}, 30_000);

it('does not persist the connection when "No" is chosen', async () => {
const ctx = { userId: 'u1', connections: { create: vi.fn() }, history: {} };
Expand All @@ -68,8 +68,8 @@ describe('ConnectionFormScreen', () => {
stdin.write('\x1b[B'); // down arrow to "No, use it just for this session"
await wait();
stdin.write('\r');
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled());
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled(), { timeout: 10_000 });

expect(ctx.connections.create).not.toHaveBeenCalled();
});
}, 30_000);
});
2 changes: 1 addition & 1 deletion apps/cli/src/tui/__tests__/ConnectionManageScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { render } from 'ink-testing-library';
import * as store from '../../runtime/store';
import { ConnectionManageScreen } from '../screens/ConnectionManageScreen';

const wait = (ms = 40) => new Promise((r) => setTimeout(r, ms));
const wait = (ms = 100) => new Promise((r) => setTimeout(r, ms));

function fakeCtx(rows: any[] = []) {
return {
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/tui/__tests__/MigrateProgressScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { ConnRef } from '../types';
// A real (not setTimeout(0)) delay for the one wait that isn't checkable via vi.waitFor:
// after the terminal outcome is already rendered, SelectInput still needs a tick to
// attach its own input listener before it can receive a keypress.
const wait = (ms = 40) => new Promise((r) => setTimeout(r, ms));
const wait = (ms = 100) => new Promise((r) => setTimeout(r, ms));

const source: ConnRef = { dialect: 'postgres', option: {}, schema: 'demo_c', label: 'demo_c' };
const target: ConnRef = { dialect: 'postgres', option: {}, schema: 'demo_d', label: 'demo_d' };
Expand Down Expand Up @@ -61,7 +61,7 @@ describe('MigrateProgressScreen', () => {
const { lastFrame } = render(
<MigrateProgressScreen source={source} target={target} continueOnError={true} onViewHistory={() => {}} onDone={() => {}} />
);
await vi.waitFor(() => expect(lastFrame()).toContain('1 failure(s)'));
await vi.waitFor(() => expect(lastFrame()).toContain('1 failure(s)'), { timeout: 10_000 });

expect(lastFrame()).toContain('boom');
});
Expand Down
2 changes: 2 additions & 0 deletions apps/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"test:headed": "HEADLESS=false vitest run --config vitest.config.ts",
"test:smoke": "vitest run --config vitest.config.ts src/tests/smoke.test.ts",
"test:smoke:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/smoke.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",
"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",
"test:errors": "vitest run --config vitest.config.ts src/tests/auto-error-catch.test.ts",
"test:errors:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/auto-error-catch.test.ts",
"test:postgres": "vitest run --config vitest.config.ts src/tests/dialects/postgres.test.ts",
Expand Down
34 changes: 33 additions & 1 deletion apps/e2e/scripts/run-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,41 @@ const results = [];
const bar = '─'.repeat(60);

console.log('\n' + bar);
console.log(` Fox E2E — running ${configured.length} dialect(s)`);
console.log(` Fox E2E — SQL Editor + ${configured.length} dialect(s)`);
console.log(bar + '\n');

// Always run SQL Editor suites first (self-seeding SQLite; no Docker required).
const ALWAYS = [
{ key: 'sql-editor', file: 'src/tests/sql-editor-smoke.test.ts src/tests/sql-editor-sqlite.test.ts', label: 'SQL Editor' },
];

for (const suite of ALWAYS) {
const start = Date.now();
const logFile = join(logDir, `${suite.key}.log`);
process.stdout.write(`▶ ${suite.label.padEnd(12)} `);
let passed = false;
let output = '';
try {
output = execSync(`${HEADED}${VITEST} ${suite.file}`, {
cwd: ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 300_000,
env: { ...process.env },
}).toString();
passed = true;
process.stdout.write('✓ PASS');
} catch (err) {
output = (err.stdout ?? '').toString() + '\n' + (err.stderr ?? '').toString();
passed = false;
process.stdout.write('✗ FAIL');
}
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(` (${elapsed}s) → log: logs/${suite.key}.log`);
writeFileSync(logFile, output, 'utf8');
const failLines = output.split('\n').filter((l) => l.includes('FAIL') || l.includes('Error') || l.includes('✗'));
results.push({ ...suite, passed, elapsed, logFile, failLines });
}

for (const dialect of configured) {
const start = Date.now();
const logFile = join(logDir, `${dialect.key}.log`);
Expand Down
6 changes: 6 additions & 0 deletions apps/e2e/src/pages/AppPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export class AppPage {
async open(): Promise<void> {
await this.page.goto(BASE_URL);
await waitFor(this.page, '[data-testid="toolbar"]');
// One-time signup wizard (if the local metadata DB has never seen it).
const skip = this.page.getByRole('button', { name: /skip for now/i });
if (await skip.isVisible().catch(() => false)) {
await skip.click();
await waitFor(this.page, '[data-testid="toolbar"]');
}
}

// ── Source side ─────────────────────────────────────────────────────────
Expand Down
178 changes: 178 additions & 0 deletions apps/e2e/src/pages/SqlEditorPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import type { Page } from 'playwright';
import { clickWhen, waitFor, fillInput } from '../helpers/driver.js';

/**
* Page object for the SQL Editor workspace (view switcher + run against
* saved credentials). Selectors match data-testid attributes in the React UI.
*/
export class SqlEditorPage {
constructor(private page: Page) {}

/** Dismiss session-password / write-confirm overlays that block clicks. */
async dismissOverlays(): Promise<void> {
const pwd = this.page.locator('[data-testid="sql-session-password"]');
if (await pwd.isVisible().catch(() => false)) {
await this.page.click('[data-testid="sql-session-password-cancel"]');
await pwd.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => undefined);
}
const write = this.page.locator('[data-testid="sql-write-confirm"]');
if (await write.isVisible().catch(() => false)) {
// Cancel write confirm (backdrop click).
await write.click({ position: { x: 8, y: 8 } });
await write.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => undefined);
}
// First-run signup wizard (full-page, not a z-100 overlay).
const skipSignup = this.page.getByRole('button', { name: /skip for now/i });
if (await skipSignup.isVisible().catch(() => false)) {
await skipSignup.click();
await this.page.waitForTimeout(300);
}
}

async openView(): Promise<void> {
await clickWhen(this.page, '[data-testid="view-sql-editor-btn"]');
await waitFor(this.page, '[data-testid="sql-editor-view"]');
// Schema explorer may prompt for a password on auto-load — clear it so
// later clicks aren't blocked. Callers that need the password should
// submit via checkConnection / submitSessionPassword instead.
await this.page.waitForTimeout(400);
await this.dismissOverlays();
}

async isEditorVisible(): Promise<boolean> {
return this.page.locator('[data-testid="sql-editor-view"]').isVisible();
}

async openCredentials(): Promise<void> {
await clickWhen(this.page, '[data-testid="credentials-btn"]');
await waitFor(this.page, '[data-testid="cred-manager"]');
}

async closeCredentials(): Promise<void> {
await clickWhen(this.page, '[data-testid="cred-close-btn"]');
await this.page.waitForSelector('[data-testid="cred-manager"]', { state: 'detached', timeout: 10_000 });
}

/** Save a SQLite file path as a named credential (password saved so Run/schema don't re-prompt). */
async addSqliteCredential(name: string, dbPath: string): Promise<void> {
await this.openCredentials();
await clickWhen(this.page, '[data-testid="cred-add-btn"]');
await waitFor(this.page, '[data-testid="conn-modal"]');
await fillInput(this.page, '[data-testid="conn-name-input"]', name);
await this.page.selectOption('[data-testid="conn-dialect-select"]', 'sqlite');
await fillInput(this.page, '[data-testid="conn-database-input"]', dbPath);
// SQLite ignores the password, but hasPassword must be true so the SQL
// Editor doesn't open a session-password modal on schema warm / check.
await fillInput(this.page, '[data-testid="conn-password-input"]', 'unused');
await this.page.locator('[data-testid="conn-save-password"]').check();
await this.page.click('[data-testid="conn-load-schema-btn"]');
await this.page.waitForSelector(
'[data-testid="conn-test-success"], [data-testid="conn-test-failed"]',
{ timeout: 25_000 }
);
const failed = await this.page.locator('[data-testid="conn-test-failed"]').isVisible();
if (failed) {
const err = (await this.page.locator('[data-testid="conn-test-failed"]').textContent()) ?? 'load failed';
throw new Error(`SQLite credential load failed for ${dbPath}: ${err}`);
}
await this.page.click('[data-testid="conn-save-btn"]');
await this.page.waitForSelector('[data-testid="conn-modal"]', { state: 'detached', timeout: 10_000 });
await this.closeCredentials();
}

async checkConnection(name: string): Promise<void> {
const sel = `[data-testid="sql-conn-check-${name}"]`;
await waitFor(this.page, sel, 15_000);
const box = this.page.locator(sel);
if (!(await box.isChecked())) await box.check();
}

async setSql(sql: string): Promise<void> {
await this.dismissOverlays();
// Monaco uses a hidden textarea; focus then replace via select-all + type.
const editor = this.page.locator('.monaco-editor').first();
await editor.click();
const mod = process.platform === 'darwin' ? 'Meta' : 'Control';
await this.page.keyboard.press(`${mod}+KeyA`);
await this.page.keyboard.press('Backspace');
await this.page.keyboard.type(sql, { delay: 8 });
// Debounced gutter + store onChange (~200ms).
await this.page.waitForTimeout(350);
}

async run(): Promise<void> {
await this.dismissOverlays();
await clickWhen(this.page, '[data-testid="sql-run-btn"]');
}

async waitForResults(timeoutMs = 30_000): Promise<void> {
await this.page.waitForSelector(
'[data-testid="sql-results-by-credential"], [data-testid="sql-results-side-by-side"]',
{ timeout: timeoutMs }
);
}

async resultsText(): Promise<string> {
const byCred = this.page.locator('[data-testid="sql-results-by-credential"]');
const side = this.page.locator('[data-testid="sql-results-side-by-side"]');
if (await byCred.isVisible()) return (await byCred.innerText()) ?? '';
if (await side.isVisible()) return (await side.innerText()) ?? '';
return '';
}

async addTab(): Promise<void> {
await this.dismissOverlays();
await clickWhen(this.page, '[data-testid="sql-tab-add"]');
}

async openSyncView(): Promise<void> {
await this.dismissOverlays();
await clickWhen(this.page, '[data-testid="view-sync-btn"]');
}

async setLayoutSideBySide(): Promise<void> {
await this.dismissOverlays();
await clickWhen(this.page, '[data-testid="sql-layout-side-by-side"]');
await waitFor(this.page, '[data-testid="sql-results-side-by-side"]');
}

async setLayoutByCredential(): Promise<void> {
await this.dismissOverlays();
await clickWhen(this.page, '[data-testid="sql-layout-by-credential"]');
}

async tabCount(): Promise<number> {
return this.page.locator('[data-testid="sql-editor-tabs"] [role="tab"]').count();
}

async statementStripVisible(): Promise<boolean> {
return this.page.locator('[data-testid="sql-statement-strip"]').isVisible();
}

async confirmWriteIfShown(): Promise<boolean> {
const dlg = this.page.locator('[data-testid="sql-write-confirm"]');
try {
await dlg.waitFor({ state: 'visible', timeout: 5_000 });
} catch {
return false;
}
await clickWhen(this.page, '[data-testid="sql-write-confirm-btn"]');
await dlg.waitFor({ state: 'detached', timeout: 10_000 });
return true;
}

async writeConfirmReadonlyWarnVisible(): Promise<boolean> {
return this.page.locator('[data-testid="sql-readonly-write-warn"]').isVisible();
}

async schemaExplorerVisible(): Promise<boolean> {
return this.page.locator('[data-testid="sql-schema-explorer"]').isVisible();
}

/** Wipe persisted editor tabs so tests start from a clean Query 1. */
async resetPersistedEditorState(): Promise<void> {
await this.page.evaluate(() => {
localStorage.removeItem('foxschema-sql-editor');
});
}
}
61 changes: 61 additions & 0 deletions apps/e2e/src/tests/sql-editor-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* SQL Editor smoke tests — no DB required (dev server only).
* Covers view switcher, empty checklist copy, tabs, schema explorer chrome.
*/
import { describe, it, beforeAll, afterAll, expect } from 'vitest';
import type { Page } from 'playwright';
import { buildDriver, quitDriver, BASE_URL } from '../helpers/driver.js';
import { AppPage } from '../pages/AppPage.js';
import { SqlEditorPage } from '../pages/SqlEditorPage.js';

let driver: Page;
let app: AppPage;
let sql: SqlEditorPage;

beforeAll(async () => {
driver = await buildDriver();
app = new AppPage(driver);
sql = new SqlEditorPage(driver);
});

afterAll(async () => {
await quitDriver(driver);
});

describe('SQL Editor smoke', () => {
it('opens the SQL Editor view from the toolbar', async () => {
await app.open();
await sql.resetPersistedEditorState();
await driver.reload();
await driver.waitForSelector('[data-testid="toolbar"]');
await sql.openView();
expect(await sql.isEditorVisible()).toBe(true);
});

it('shows the schema explorer and empty-connection hint', async () => {
expect(await sql.schemaExplorerVisible()).toBe(true);
const body = await driver.locator('[data-testid="sql-editor-view"]').innerText();
// Either no saved connections yet, or leftovers from a prior local session —
// explorer chrome must still be present either way.
expect(body).toMatch(/Schema|Destination servers/i);
});

it('can add a second editor tab', async () => {
const before = await sql.tabCount();
await sql.addTab();
expect(await sql.tabCount()).toBe(before + 1);
});

it('Run stays disabled with no SQL / no checked connections', async () => {
const btn = driver.locator('[data-testid="sql-run-btn"]');
expect(await btn.isDisabled()).toBe(true);
});

it('switches back to Sync view', async () => {
await sql.openSyncView();
expect(await sql.isEditorVisible()).toBe(false);
expect(await driver.locator('[data-testid="toolbar"]').isVisible()).toBe(true);
// Keep BASE_URL sanity — sync view does not mount sql-editor-view.
expect(driver.url()).toContain(new URL(BASE_URL).host);
});
});
Loading
Loading