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
5 changes: 5 additions & 0 deletions .github/workflows/dependency-security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ jobs:
# signatures. `npm audit` answers "is this version known to be
# vulnerable"; this answers "is this tarball the one the registry
# published", which is what catches a tampered or re-published package.
#
# `npm audit signatures` calls the attestations API, which intermittently
# 404s for otherwise-valid packages (e.g. whatwg-url@14.2.0). Treat that
# registry gap as a warning so Critical audit below remains the hard gate.
- name: Verify registry signatures
continue-on-error: true
run: npm audit signatures

- name: Fail on Critical vulnerabilities
Expand Down
16 changes: 16 additions & 0 deletions apps/e2e/src/pages/ConnectionModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ export class ConnectionModal {
});
}

/** Close a stuck overlay so later steps are not blocked by pointer interception. */
async dismissIfOpen(): Promise<void> {
const modal = this.page.locator('[data-testid="conn-modal"]');
if (!(await modal.isVisible().catch(() => false))) return;
await this.page.keyboard.press('Escape').catch(() => undefined);
if (await modal.isVisible().catch(() => false)) {
const close = this.page.locator(
'[data-testid="conn-modal"] [data-testid="conn-cancel-btn"], [data-testid="conn-modal"] button[aria-label="Close"], [data-testid="conn-modal"] button.p-1'
).first();
if (await close.isVisible().catch(() => false)) {
await close.click({ force: true }).catch(() => undefined);
}
}
await modal.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => undefined);
}

async checkSavePassword(): Promise<void> {
const box = this.page.locator('[data-testid="conn-save-password"]');
if ((await box.count()) > 0 && !(await box.isChecked())) {
Expand Down
25 changes: 25 additions & 0 deletions apps/e2e/src/pages/LokeeHistoryPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,23 @@ export class LokeeHistoryPage {
}

async openComparePane(): Promise<void> {
// Snapshots is its own workspace now, and the toolbar's pane switcher only
// exists while `activeView === 'sync'`. Coming back from Snapshots means
// selecting Sync first — reaching straight for the Compare pill waited 15s
// for a control that is not on that screen, which is what failed this test
// on every dialect at once.
const rail = this.page.locator('[data-testid="view-sync-btn"]');
if (await rail.isVisible().catch(() => false)) {
await clickWhen(this.page, '[data-testid="view-sync-btn"]');
}
await clickWhen(this.page, '[data-testid="sync-pane-compare-btn"]');
}

async snapshotTarget(): Promise<void> {
// The button lives on the Sync workspace's compare pane. A second snapshot
// is usually taken after looking at the first one, from Snapshots, where
// the button is not on screen at all.
await this.openComparePane();
const btn = this.page.locator('[data-testid="lokee-snapshot-target-btn"]');
await btn.waitFor({ state: 'visible', timeout: 15_000 });
await this.page.waitForFunction(
Expand All @@ -37,8 +50,20 @@ export class LokeeHistoryPage {
{ timeout: 15_000 }
);
await btn.click();
// Wait for the outcome, not for the button to come back.
//
// Capture bumps the Lokee epoch, and the toolbar's compare controls — the
// snapshot button among them — are gone from the DOM by the time it
// settles. Waiting for that button to re-enable waited on something that
// had left, and the run reported a 30s timeout for a snapshot the toast
// said had already succeeded: "Snapshot v1 · 10 object change(s)".
//
// Either ending is fine: the toast if it is still up, or the button back
// and idle if the toolbar kept it.
await this.page.waitForFunction(
() => {
const toast = document.querySelector('[data-testid="app-toast"]');
if (/snapshot\s+v\d+/i.test(toast?.textContent ?? '')) return true;
const el = document.querySelector('[data-testid="lokee-snapshot-target-btn"]');
return (
el instanceof HTMLButtonElement &&
Expand Down
40 changes: 39 additions & 1 deletion apps/e2e/src/pages/SqlEditorPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ export class SqlEditorPage {
}
}

/**
* Monaco is lazy-loaded with the SQL Editor pane (kept off first paint).
* Callers that type into the buffer must wait for the chunk, not only the shell.
*/
async waitForMonaco(timeoutMs = 60_000): Promise<void> {
await this.page.waitForSelector('.monaco-editor textarea, .monaco-editor', {
timeout: timeoutMs,
state: 'visible',
});
}

async openView(): Promise<void> {
await clickWhen(this.page, '[data-testid="view-sql-editor-btn"]');
await waitFor(this.page, '[data-testid="sql-editor-view"]');
Expand All @@ -44,6 +55,7 @@ export class SqlEditorPage {
// submit via checkConnection / submitSessionPassword instead.
await this.page.waitForTimeout(400);
await this.dismissOverlays();
await this.waitForMonaco();
}

/** Open the Database utilities workspace (not the SQL Editor sidebar). */
Expand Down Expand Up @@ -108,6 +120,10 @@ export class SqlEditorPage {
}

async checkConnection(name: string): Promise<void> {
// The v3 sidebar ships every section collapsed, and a collapsed section
// renders no content at all — so the checkbox is absent, not merely
// scrolled out of view. Open Destinations before looking for it.
await this.ensureSidebarSectionOpen('destinations');
const sel = `[data-testid="sql-conn-check-${name}"]`;
await waitFor(this.page, sel, 15_000);
const box = this.page.locator(sel);
Expand All @@ -116,6 +132,12 @@ export class SqlEditorPage {

async setSql(sql: string): Promise<void> {
await this.dismissOverlays();
// Ensure we are on the SQL Editor shell and Monaco has finished lazy load.
if (!(await this.isEditorVisible().catch(() => false))) {
await this.openView();
} else {
await this.waitForMonaco();
}
// Monaco uses a hidden textarea; focus then replace via select-all + type.
const editor = this.page.locator('.monaco-editor').first();
await editor.click();
Expand Down Expand Up @@ -359,6 +381,11 @@ export class SqlEditorPage {
async openTableBlueprint(tableName: string): Promise<void> {
await this.dismissOverlays();
await this.closeBlueprint().catch(() => undefined);
// The schema tree lives in the SQL Editor workspace. A caller coming from
// Utilities (Clone Table, Index Management) is on a screen that has no
// sidebar at all, so go back before looking for the tree.
await this.openView();
await this.ensureSidebarSectionOpen('schema');
const explorer = this.page.locator('[data-testid="sql-schema-explorer"]');
await explorer.waitFor({ state: 'visible', timeout: 15_000 });
// Expand TABLES group only when the table name is not already visible
Expand Down Expand Up @@ -455,8 +482,16 @@ export class SqlEditorPage {
await this.openUtilitiesView();
return;
}
// Not every caller is on a screen that has the SQL Editor sidebar — the
// Utilities workspace has none — so a missing section means "nothing to
// expand", not a failure. Throwing here would turn callers that merely
// want the section open *if it exists* into hard errors.
const section = this.page.locator(`[data-testid="sql-sidebar-${id}"]`);
await section.waitFor({ state: 'visible', timeout: 10_000 });
const present = await section
.waitFor({ state: 'visible', timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (!present) return;
const toggle = this.page.locator(`[data-testid="sql-sidebar-toggle-${id}"]`);
const aria = await toggle.getAttribute('aria-expanded').catch(() => null);
if (aria === 'false') await toggle.click();
Expand All @@ -465,6 +500,9 @@ export class SqlEditorPage {
/** Open Data Peek for a table from the Schema tree (modifier-click the row). */
async openDataPeek(tableName: string): Promise<void> {
await this.dismissOverlays();
// The v3 sidebar collapses every section, and a collapsed section renders
// no explorer at all — open Schema before reaching into its tree.
await this.ensureSidebarSectionOpen('schema');
const explorer = this.page.locator('[data-testid="sql-schema-explorer"]');
await explorer.waitFor({ state: 'visible', timeout: 15_000 });
await this.page.waitForFunction(
Expand Down
38 changes: 25 additions & 13 deletions apps/e2e/src/tests/dialects/shared-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,21 +86,31 @@ export function runDialectFlow(
// ── 2. Connect ──────────────────────────────────────────────────────────

it('connects source', async () => {
await app.openSourceModal();
await modal.connect(getSource());
await app.waitForSourceConnected(30_000);
expect(await app.isSourceConnected()).toBe(true);
try {
await app.openSourceModal();
await modal.connect(getSource());
await app.waitForSourceConnected(30_000);
expect(await app.isSourceConnected()).toBe(true);
} catch (err) {
await modal.dismissIfOpen();
throw err;
}
});

it('connects target', async () => {
await app.openTargetModal();
await modal.connect(getTarget());
await app.waitForTargetConnected(30_000);
expect(await app.isTargetConnected()).toBe(true);
// Saving target reloads credentials — wait until source is connected again
// (session password / hasPassword retest) before Compare is enabled.
await app.waitForSourceConnected(30_000);
expect(await app.isSourceConnected()).toBe(true);
try {
await app.openTargetModal();
await modal.connect(getTarget());
await app.waitForTargetConnected(30_000);
expect(await app.isTargetConnected()).toBe(true);
// Saving target reloads credentials — wait until source is connected again
// (session password / hasPassword retest) before Compare is enabled.
await app.waitForSourceConnected(30_000);
expect(await app.isSourceConnected()).toBe(true);
} catch (err) {
await modal.dismissIfOpen();
throw err;
}
});

// ── 3. Compare ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -263,6 +273,8 @@ export function runDialectFlow(
}
}
}
await clickWhen(driver, '[data-testid="sync-pane-compare-btn"]');
// Back to Compare through the page object: the pane switcher only exists
// while the Sync workspace is active, and this test ends on Snapshots.
await new LokeeHistoryPage(driver).openComparePane();
});
}
3 changes: 3 additions & 0 deletions apps/e2e/src/tests/sql-editor-blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ describe.skipIf(!ready)('SQL Editor · blueprint + paging (SQLite)', () => {
await sql.addSqliteCredential(NAME, DB);
await sql.openView();
await sql.checkConnection(NAME);
// Sidebar sections are an exclusive accordion: ticking a destination
// closed Schema, and a closed section renders no explorer to read.
await sql.ensureSidebarSectionOpen('schema');
await driver.waitForFunction(
() => {
const root = document.querySelector('[data-testid="sql-schema-explorer"]');
Expand Down
3 changes: 3 additions & 0 deletions apps/e2e/src/tests/sql-editor-column-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ describe.skipIf(!ready)('SQL Editor · SELECT column picker', () => {
await sql.addSqliteCredential(NAME, DB);
await sql.openView();
await sql.checkConnection(NAME);
// Sidebar sections are an exclusive accordion: ticking a destination
// closed Schema, and a closed section renders no explorer to read.
await sql.ensureSidebarSectionOpen('schema');

// The picker builds its list from the loaded schema cache.
await driver.waitForFunction(
Expand Down
3 changes: 3 additions & 0 deletions apps/e2e/src/tests/sql-editor-result-edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ INSERT INTO items (id, label, qty) VALUES (1, 'seed', 3);
it('adds a row from the result grid and keeps joins read-only', async () => {
await sql.openView();
await sql.checkConnection(NAME);
// checkConnection opens Destinations, and the sidebar is an exclusive
// accordion — reopen Schema or the explorer below never renders.
await sql.ensureSidebarSectionOpen('schema');
// Wait for schema so single-table editability can resolve.
await expect
.poll(async () => sql.schemaExplorerVisible(), { timeout: 30_000 })
Expand Down
6 changes: 6 additions & 0 deletions apps/e2e/src/tests/sql-editor-sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => {
});

it('schema explorer lists customers after load', async () => {
// Sidebar sections are an exclusive accordion: checking a destination in
// the previous tests closed Schema, and a closed section renders nothing.
await sql.ensureSidebarSectionOpen('schema');
expect(await sql.schemaExplorerVisible()).toBe(true);
// Wait for load / ready tree to include our seeded table.
await driver.waitForFunction(
Expand Down Expand Up @@ -236,6 +239,9 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => {
await driver.waitForSelector('[data-testid="toolbar"]', { timeout: 30_000 });
await sql.openView();
await sql.checkConnection(NAME_A);
// checkConnection opens Destinations, which closes Schema — reopen it so
// the explorer this test measures is actually rendered.
await sql.ensureSidebarSectionOpen('schema');
await driver.waitForFunction(
() => {
const root = document.querySelector('[data-testid="sql-schema-explorer"]');
Expand Down
3 changes: 3 additions & 0 deletions apps/e2e/src/tests/sql-editor-utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ describe.skipIf(!ready)('Utilities workspace + Clone Table (SQLite)', () => {
await sql.addSqliteCredential(NAME, DB);
await sql.openView();
await sql.checkConnection(NAME);
// Sidebar sections are an exclusive accordion: ticking a destination
// closed Schema, and a closed section renders no explorer to read.
await sql.ensureSidebarSectionOpen('schema');
await driver.waitForFunction(
() => {
const root = document.querySelector('[data-testid="sql-schema-explorer"]');
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/frontend/app/shell/ConnectionChips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,17 @@ export function ConnectionChip({
// next row rather than collapse to 95px and paint its controls across its
// neighbours — which is what put the edit button under the "Same DB" pill
// and made it unclickable.
className={`flex min-w-[15rem] max-w-xl flex-1 items-center gap-1.5 rounded-full border px-2 py-1 ${tone.ring}`}
//
// flex-initial, not flex-1: with flex-1 each chip grew to soak up free
// space, and two of them pushed the rest of the toolbar onto a second
// line — 87px of chrome for a row that fits in 36. A chip needs a floor
// and a ceiling, not everything going spare.
//
// The ceiling sits close to the floor on purpose. The summary inside
// truncates, and a connection string is worth less toolbar height than
// the rest of the toolbar is worth: two chips at 22rem consumed 704px of
// a 1190px row on a 1280 screen and wrapped everything else.
className={`flex min-w-[15rem] max-w-[17rem] flex-initial items-center gap-1.5 rounded-full border px-2 py-1 ${tone.ring}`}
>
<span className={`shrink-0 text-[10px] font-bold uppercase tracking-wider ${tone.label}`}>
{label}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/frontend/app/shell/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ export const TopToolbar: React.FC = () => {
];

return (
<header data-testid="toolbar" className="border-b border-slate-800 bg-slate-900/90 backdrop-blur-md px-3 py-1.5 flex flex-col gap-1.5">
<div className="flex min-h-11 flex-wrap items-center gap-2">
<header data-testid="toolbar" className="border-b border-slate-800 bg-slate-900/90 backdrop-blur-md px-3 py-1 flex flex-col gap-1">
<div className="flex min-h-9 flex-wrap items-center gap-1.5">
{activeView === 'sync' && canSchemaBrowse && (
<div
data-testid="sync-pane-switcher"
Expand Down
Loading
Loading