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
1 change: 1 addition & 0 deletions apps/e2e/scripts/run-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ const ALWAYS = [
'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/utilities-sql-tracer.test.ts',
'src/tests/sql-editor-column-picker.test.ts',
'src/tests/sql-editor-code-cell-faker.test.ts',
'src/tests/lazy-loading.test.ts',
Expand Down
47 changes: 47 additions & 0 deletions apps/e2e/src/generated/test-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3174,6 +3174,36 @@ export const TestIds = {
/** button · Unlock */
serverInsightsUnlock: 'server-insights-unlock',
},
SqlTracerPanel: {
/** aside */
sqlTracerDetail: 'sql-tracer-detail',
/** pre */
sqlTracerDetailText: 'sql-tracer-detail-text',
/** p */
sqlTracerEmpty: 'sql-tracer-empty',
/** p */
sqlTracerError: 'sql-tracer-error',
/** input · Text, user, client… */
sqlTracerFilter: 'sql-tracer-filter',
/** select · Fox activity Live (engine sessions) */
sqlTracerMode: 'sql-tracer-mode',
/** button · Open in editor */
sqlTracerOpenEditor: 'sql-tracer-open-editor',
/** div */
sqlTracerPanel: 'sql-tracer-panel',
/** input · •••••••• */
sqlTracerPasswordDraft: 'sql-tracer-password-draft',
/** tr */
sqlTracerRow: (seq: string | number) => `sql-tracer-row-${seq}`,
/** button · Start */
sqlTracerStart: 'sql-tracer-start',
/** div */
sqlTracerStatus: 'sql-tracer-status',
/** button · Stop */
sqlTracerStop: 'sql-tracer-stop',
/** button · Unlock */
sqlTracerUnlock: 'sql-tracer-unlock',
},
UtilitiesView: {
/** div */
dbAccessModal: 'db-access-modal',
Expand All @@ -3197,6 +3227,8 @@ export const TestIds = {
utilitiesObjectSizes: 'utilities-object-sizes',
/** data · Query files */
utilitiesQueryFiles: 'utilities-query-files',
/** data · SQL Tracer */
utilitiesSqlTracer: 'utilities-sql-tracer',
/** data · System Info */
utilitiesSystemInfo: 'utilities-system-info',
/** data · User Connections */
Expand Down Expand Up @@ -4974,6 +5006,20 @@ export type TestId =
| `sql-tab-close-${string}`
| 'sql-tab-rename'
| `sql-tab-rename-btn-${string}`
| 'sql-tracer-detail'
| 'sql-tracer-detail-text'
| 'sql-tracer-empty'
| 'sql-tracer-error'
| 'sql-tracer-filter'
| 'sql-tracer-mode'
| 'sql-tracer-open-editor'
| 'sql-tracer-panel'
| 'sql-tracer-password-draft'
| `sql-tracer-row-${string}`
| 'sql-tracer-start'
| 'sql-tracer-status'
| 'sql-tracer-stop'
| 'sql-tracer-unlock'
| `sql-variable-${string}`
| 'sql-variable-add'
| 'sql-variable-add-confirm'
Expand Down Expand Up @@ -5128,6 +5174,7 @@ export type TestId =
| 'utilities-index-management'
| 'utilities-object-sizes'
| 'utilities-query-files'
| 'utilities-sql-tracer'
| 'utilities-system-info'
| 'utilities-user-connections'
| 'utilities-view'
Expand Down
181 changes: 181 additions & 0 deletions apps/e2e/src/tests/utilities-sql-tracer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Utilities → SQL Tracer, end to end.
*
* Fox activity (SQLite): a trace on a saved connection shows the statements
* run on that connection through the API — what another person or another
* tab does — with secrets masked, and a failure marked.
*
* Live (PostgreSQL): a statement still running on the engine shows from its
* session list, masked the same way, and the tracer's own probe never does.
*
* Statements are sent through /api/sql/execute rather than the editor because
* opening the editor would close the Utilities screen, and closing it stops
* the trace. Requires the web app + API, a `sqlite3` CLI for the first part
* and E2E_SOURCE_POSTGRES_* for the second.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync, execSync } from 'node:child_process';
import { mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import type { Page } from 'playwright';
import { buildDriver, clickWhen, quitDriver } from '../helpers/driver.js';
import { getSourceConfig, hasConfig } from '../helpers/db-config.js';
import { sessionCookie } from '../helpers/app-session.js';
import { deleteSavedConnections, runStatements } from '../helpers/sql-exec.js';
import { saveScreenshot } from '../helpers/screenshot.js';
import { byTestId } from '../helpers/test-ids.js';
import { AppPage } from '../pages/AppPage.js';
import { SqlEditorPage } from '../pages/SqlEditorPage.js';

const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const RUN = Date.now().toString(36);
const DIR = `/tmp/foxschema-e2e-tracer-${RUN}`;
const SQLITE = { file: join(DIR, 'traced.db'), name: `E2E Tracer SQLite ${RUN}` };
const PG_NAME = `E2E Tracer PG ${RUN}`;

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

async function connectionIdOf(name: string): Promise<string> {
const res = await fetch(`${BASE_URL}/api/connections`, { headers: { cookie: await sessionCookie() } });
const body = (await res.json()) as Array<{ id: string; name: string }> | { connections: Array<{ id: string; name: string }> };
const rows = Array.isArray(body) ? body : body.connections;
const row = rows.find((r) => r.name === name);
if (!row) throw new Error(`no saved connection named ${name}`);
return row.id;
}

/** Run statements on a saved connection, as the SQL editor does. */
async function runOnSaved(connectionId: string, statements: string[]): Promise<void> {
const res = await fetch(`${BASE_URL}/api/sql/execute`, {
method: 'POST',
headers: { 'content-type': 'application/json', cookie: await sessionCookie() },
body: JSON.stringify({ connectionId, statements }),
});
if (!res.ok) throw new Error(`execute failed: ${res.status} ${await res.text()}`);
}

describe('Utilities · SQL Tracer', () => {
let driver: Page;
let sql: SqlEditorPage;
const made: string[] = [];

const rowTexts = () =>
driver.$$eval('[data-testid^="sql-tracer-row-"]', (rows) => rows.map((r) => r.lastElementChild?.textContent ?? ''));

async function openTracerOn(name: string): Promise<void> {
await sql.openUtilitiesView();
await clickWhen(driver, byTestId('utilities-sql-tracer'));
await driver.waitForFunction(
(n) =>
[...((document.querySelector('[data-testid="utilities-connection"]') as HTMLSelectElement | null)?.options ?? [])].some((o) =>
(o.textContent ?? '').includes(n)
),
name,
{ timeout: 15_000 }
);
await sql.selectUtilityConnection(name, 'utilities-connection');
await driver.waitForSelector(byTestId('sql-tracer-panel'));
}

beforeAll(async () => {
driver = await buildDriver();
sql = new SqlEditorPage(driver);
await new AppPage(driver).open();
}, 120_000);

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

describe.skipIf(!hasSqlite3())('Fox activity', () => {
let connectionId = '';

beforeAll(async () => {
rmSync(DIR, { recursive: true, force: true });
mkdirSync(DIR, { recursive: true });
execFileSync('sqlite3', [SQLITE.file], { input: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT);' });
await sql.addSqliteCredential(SQLITE.name, SQLITE.file);
made.push(SQLITE.name);
connectionId = await connectionIdOf(SQLITE.name);
}, 120_000);

it('shows each statement run on the connection, masked, until stopped', async () => {
await openTracerOn(SQLITE.name);
await driver.locator(byTestId('sql-tracer-mode')).selectOption('fox');
await clickWhen(driver, byTestId('sql-tracer-start'));
await driver.waitForSelector(byTestId('sql-tracer-stop'));

await runOnSaved(connectionId, [
`INSERT INTO items (id, name) VALUES (1, 'tracer-${RUN}')`,
`SELECT name FROM items /* PASSWORD 'hunter2' */`,
'SELECT * FROM no_such_table',
]);

await driver.waitForFunction(() => document.querySelectorAll('[data-testid^="sql-tracer-row-"]').length >= 3, null, {
timeout: 15_000,
});
const texts = await rowTexts();
expect(texts[0]).toContain(`tracer-${RUN}`);
expect(texts[1]).toContain("PASSWORD '***'");
expect(texts.join('\n')).not.toContain('hunter2');

// The failed one carries its error in the detail pane.
await driver.locator(byTestId('sql-tracer-row-3')).click();
await driver.waitForSelector(byTestId('sql-tracer-detail'));
expect(await driver.locator(byTestId('sql-tracer-detail')).innerText()).toMatch(/no such table/i);
await saveScreenshot(driver, 'sql-tracer-fox-activity');

await clickWhen(driver, byTestId('sql-tracer-stop'));
await driver.waitForSelector(byTestId('sql-tracer-start'));
expect(await driver.locator(byTestId('sql-tracer-status')).innerText()).toContain('Stopped');

// Nothing more arrives once stopped.
await runOnSaved(connectionId, ['SELECT 42']);
await driver.waitForTimeout(2_500);
expect((await rowTexts()).some((t) => t.includes('42'))).toBe(false);
}, 90_000);
});

describe.skipIf(!hasConfig('postgres'))('Live (PostgreSQL)', () => {
beforeAll(async () => {
await sql.addCredential(PG_NAME, getSourceConfig('postgres')!);
made.push(PG_NAME);
}, 120_000);

it('shows a statement still running, masked, and not its own probe', async () => {
await openTracerOn(PG_NAME);
await driver.locator(byTestId('sql-tracer-mode')).selectOption('live');
await clickWhen(driver, byTestId('sql-tracer-start'));
await driver.waitForSelector(byTestId('sql-tracer-stop'));

const marker = `tracer-${RUN}`;
// Not awaited: it has to be running while the tracer reads the sessions.
const running = runStatements('postgres', [`SELECT pg_sleep(6) /* ${marker} PASSWORD 'hunter2' */`]);

await driver.waitForFunction(
(m) =>
[...document.querySelectorAll('[data-testid^="sql-tracer-row-"]')].some((r) => (r.textContent ?? '').includes(m)),
marker,
{ timeout: 15_000 }
);
const texts = await rowTexts();
const mine = texts.find((t) => t.includes(marker))!;
expect(mine).toContain("PASSWORD '***'");
expect(texts.join('\n')).not.toContain('hunter2');
expect(texts.some((t) => /pg_stat_activity/i.test(t))).toBe(false);
await saveScreenshot(driver, 'sql-tracer-live-postgres');

await clickWhen(driver, byTestId('sql-tracer-stop'));
await running;
}, 90_000);
});
});
42 changes: 42 additions & 0 deletions apps/web/src/frontend/features/utilities/api/sqlTraceApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* The SQL Tracer API: start a trace on a saved connection, read its events
* since the last one seen, stop it.
*/
import type { TraceEvent, TraceMode } from '@foxschema/ui-shared';
import { api } from '@/shared/api/client';

export type TraceStopReason = 'stopped' | 'time' | 'events' | 'replaced' | 'error';

export interface TraceStatus {
id: string;
connectionId: string;
mode: TraceMode;
startedAt: string;
endsAt: string;
running: boolean;
stopReason: TraceStopReason | null;
error: string | null;
eventCount: number;
}

export async function startTrace(connectionId: string, mode: TraceMode, password?: string): Promise<TraceStatus> {
const { status } = await api.post<{ status: TraceStatus }>('/sql-trace/start', { connectionId, mode, password });
return status;
}

export function traceEvents(id: string, after: number, signal?: AbortSignal) {
return api.get<{ status: TraceStatus; events: TraceEvent[] }>(`/sql-trace/${encodeURIComponent(id)}/events`, {
query: { after },
signal,
noStore: true,
});
}

export async function stopTrace(id: string): Promise<TraceStatus> {
const { status } = await api.post<{ status: TraceStatus }>(`/sql-trace/${encodeURIComponent(id)}/stop`);
return status;
}
Loading
Loading