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
2 changes: 1 addition & 1 deletion .cursor/rules/sql-editor-agent-memory.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ FoxSchema = schema diff/migration + SQL Editor + workspaces. Destinations checkl
- **Monarch flag**: module-local ready flag only — never write onto Monaco ESM exports.

## Notebook cells + Data peek + Node `sql` bridge
- One Monaco buffer; cell banding + statement strip `In [n]:` / per-cell ▶; results `Out [n]:`.
- One Monaco buffer; cell banding + the run rail left of the editor (`StatementRunGutter`: `In [n]` number, per-cell ▶, include checkbox); results `Out [n]:`.
- Data peek: `DataPeekPanel` from SqlEditorView; FK rust `#9a3412`; `tablePreview.ts` + store APIs.
- Node bridge: worker has **no DB handle**; `sql\`...\`` → `cell-query` / `cell-query-result`; bind params only; `sql.raw` sole unescaped path; no cross-call transactions.
- SQLite: read-write open, `.run()` for non-SELECT; `resolveFkReferencedColumns` needs real identifier strings.
Expand Down
1 change: 1 addition & 0 deletions apps/e2e/scripts/run-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const ALWAYS = [
'src/tests/sql-editor-code-cell-faker.test.ts',
'src/tests/lazy-loading.test.ts',
'src/tests/sql-editor-result-edit.test.ts',
'src/tests/sql-editor-run-rail.test.ts',
'src/tests/sql-editor-peek-row-form.test.ts',
'src/tests/sql-editor-peek-stacking.test.ts',
'src/tests/sql-editor-compare-multi-target.test.ts',
Expand Down
45 changes: 19 additions & 26 deletions apps/e2e/src/generated/test-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1748,6 +1748,12 @@ export const TestIds = {
},
},
shared: {
DriverInstallHints: {
/** p */
driverInstallError: 'driver-install-error',
/** p */
driverInstallNeedsAdmin: 'driver-install-needs-admin',
},
ErrorBoundary: {
/** button · Dismiss */
appErrorBoundaryDismiss: 'app-error-boundary-dismiss',
Expand Down Expand Up @@ -2185,6 +2191,10 @@ export const TestIds = {
sqlInstallDriver: (connectionId: string | number) => `sql-install-driver-${connectionId}`,
/** div */
sqlInstallDriverBanner: (connectionId: string | number) => `sql-install-driver-${connectionId}-banner`,
/** button · Install the driver package on the server */
sqlPaneInstallDriver: (connectionId: string | number) => `sql-pane-install-driver-${connectionId}`,
/** div */
sqlPaneInstallDriverBanner: (connectionId: string | number) => `sql-pane-install-driver-${connectionId}-banner`,
/** div · Drag to resize this table */
sqlPaneResize: 'sql-pane-resize',
/** button · Retry this server */
Expand Down Expand Up @@ -2529,32 +2539,16 @@ export const TestIds = {
sqlVariables: 'sql-variables',
},
StatementRunGutter: {
/** button */
sqlStatementCopy: (i: string | number) => `sql-statement-copy-${i}`,
/** div · Statement run checks */
sqlStatementGutter: 'sql-statement-gutter',
/** input */
sqlStatementInclude: (i: string | number) => `sql-statement-include-${i}`,
/** button */
sqlStatementRun: (i: string | number) => `sql-statement-run-${i}`,
},
StatementStrip: {
/** div */
sqlStatementCell: (i: string | number) => `sql-statement-cell-${i}`,
/** button */
sqlStatementCopy: 'sql-statement-copy',
/** button */
sqlStatementCopyRow: (i: string | number) => `sql-statement-copy-row-${i}`,
/** input */
sqlStatementInclude: (i: string | number) => `sql-statement-include-${i}`,
/** button · In [ ]: */
sqlStatementReveal: (i: string | number) => `sql-statement-reveal-${i}`,
sqlStatementLabel: (i: string | number) => `sql-statement-label-${i}`,
/** button */
sqlStatementRun: (i: string | number) => `sql-statement-run-${i}`,
/** div */
sqlStatementStrip: 'sql-statement-strip',
/** div · Resize statement list */
sqlStatementStripResize: 'sql-statement-strip-resize',
/** div */
sqlStatementValuesPopover: 'sql-statement-values-popover',
},
TableBlueprintModal: {
/** button · Add column */
Expand Down Expand Up @@ -4199,6 +4193,8 @@ export type TestId =
| `diff-tree-toggle-selection-${string}`
| 'diff-unsupported'
| 'dismiss-warnings-btn'
| 'driver-install-error'
| 'driver-install-needs-admin'
| 'error-banner'
| 'execute-btn'
| 'feedback-cancel'
Expand Down Expand Up @@ -4761,6 +4757,8 @@ export type TestId =
| 'sql-open-object-source-btn'
| 'sql-page-next'
| 'sql-page-prev'
| `sql-pane-install-driver-${string}`
| `sql-pane-install-driver-${string}-banner`
| 'sql-pane-refresh'
| 'sql-pane-resize'
| `sql-pane-retry-${string}`
Expand Down Expand Up @@ -4891,16 +4889,11 @@ export type TestId =
| `sql-sidebar-resize-${string}`
| `sql-sidebar-toggle-${string}`
| 'sql-sidebar-utilities'
| `sql-statement-cell-${string}`
| 'sql-statement-copy'
| `sql-statement-copy-row-${string}`
| `sql-statement-copy-${string}`
| 'sql-statement-gutter'
| `sql-statement-include-${string}`
| `sql-statement-reveal-${string}`
| `sql-statement-label-${string}`
| `sql-statement-run-${string}`
| 'sql-statement-strip'
| 'sql-statement-strip-resize'
| 'sql-statement-values-popover'
| 'sql-tab-add'
| `sql-tab-close-${string}`
| 'sql-tab-rename'
Expand Down
80 changes: 80 additions & 0 deletions apps/e2e/src/tests/sql-editor-run-rail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* The run rail left of the SQL editor, in a real browser.
*
* #542 moved each statement's ▶ and run checkbox from the strip under the
* editor into this rail and dropped what the strip said about each statement:
* its In [n] number, Safe mode's warning on a write with no WHERE, selecting a
* statement, and copying it with its values. Two statements on one line also
* drew their controls on top of each other.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { Page } from 'playwright';
import { buildDriver, quitDriver } from '../helpers/driver.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 SQL = 'UPDATE t SET a = 1 WHERE id = 1;\nDELETE FROM t;\nSELECT 1; SELECT 2;\n';
const mod = process.platform === 'darwin' ? 'Meta' : 'Control';

describe('SQL Editor · run rail', () => {
let driver: Page;
let sql: SqlEditorPage;

beforeAll(async () => {
driver = await buildDriver();
sql = new SqlEditorPage(driver);
await new AppPage(driver).open();
await sql.resetPersistedEditorState();
await driver.reload();
await driver.waitForSelector(byTestId('toolbar'));
await sql.openView();
await sql.waitForMonaco();
await driver.locator('.monaco-editor').first().click();
await driver.keyboard.press(`${mod}+KeyA`);
await driver.keyboard.insertText(SQL);
await driver.waitForSelector(byTestId('sql-statement-label-3'), { timeout: 15_000 });
}, 180_000);

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

it('numbers each statement and marks the writes Safe mode will confirm', async () => {
const label = (i: number) => driver.locator(byTestId(`sql-statement-label-${i}`));
expect(await label(0).innerText()).toBe('1');
expect(await label(0).getAttribute('data-dml')).toBe('UPD');
expect(await label(1).innerText()).toBe('2!');
expect(await label(1).getAttribute('data-dml')).toBe('DEL!');
expect(await label(1).getAttribute('title')).toMatch(/no WHERE — may affect all rows/);
expect(await label(1).evaluate((el) => getComputedStyle(el).color)).not.toBe(
await label(2).evaluate((el) => getComputedStyle(el).color)
);
expect(await label(2).getAttribute('data-dml')).toBeNull();
await saveScreenshot(driver, 'run-rail-labels');
});

it('gives two statements on one line a row each, both clickable', async () => {
const box = async (i: number) => (await driver.locator(byTestId(`sql-statement-include-${i}`)).boundingBox())!;
const [a, b] = [await box(2), await box(3)];
expect(b.y - a.y).toBeGreaterThanOrEqual(14);
await driver.locator(byTestId('sql-statement-include-2')).click();
expect(await driver.locator(byTestId('sql-statement-include-2')).isChecked()).toBe(true);
expect(await driver.locator(byTestId('sql-statement-include-3')).isChecked()).toBe(false);
await driver.locator(byTestId('sql-statement-include-2')).click();
});

it('selects a statement when its number is clicked, so Run runs that one', async () => {
await driver.locator(byTestId('sql-statement-label-1')).click();
await expect.poll(() => driver.locator(byTestId('sql-run-btn')).innerText(), { timeout: 5_000 }).toMatch(/Run selection/);
});

it('copies a statement from its row', async () => {
await driver.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await driver.locator(byTestId('sql-statement-label-1')).hover();
await driver.locator(byTestId('sql-statement-copy-1')).click();
await expect.poll(() => driver.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 }).toBe('DELETE FROM t;');
await saveScreenshot(driver, 'run-rail-copied');
});
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@/shared/api/schemaApi', () => ({
checkDriver: vi.fn().mockResolvedValue({ installed: true }),
fetchSchemaList: vi.fn().mockResolvedValue([]),
installDriver: vi.fn().mockResolvedValue(undefined),
invalidateCache: vi.fn(),
}));

import { checkDriver, installDriver } from '@/shared/api/schemaApi';
import { useAuthStore } from '@/platform/authStore';
import { clearDriverStatus } from '@/shared/lib/driverStatus';
import { ConnectionModal } from './ConnectionModal';

const signIn = (role: 'admin' | 'editor') =>
useAuthStore.setState({
user: { id: 'u1', email: 'u1@example.test', onboardingCompleted: true, role, permissions: [] },
status: 'ready',
});

beforeEach(() => {
clearDriverStatus();
signIn('admin');
});

describe('ConnectionModal', () => {
it('verifies Azure SQL server certificates by default', () => {
const onSave = vi.fn();
Expand Down Expand Up @@ -37,4 +52,24 @@ describe('ConnectionModal', () => {
});
expect(options.connectionString).not.toContain('TrustServerCertificate=True');
});

describe('a missing driver', () => {
const missing = { provider: 'redis', packageName: 'redis', installed: false, installCommand: 'npm install redis' };
const modal = () => <ConnectionModal open dialect="redis" onClose={() => undefined} onSave={() => undefined} />;

it('offers Install to an admin', async () => {
vi.mocked(checkDriver).mockResolvedValue(missing);
render(modal());
fireEvent.click(await screen.findByTestId('conn-install-driver-package'));
await waitFor(() => expect(installDriver).toHaveBeenCalledWith('redis'));
});

it('offers no Install without the Install drivers permission, and gives the command instead', async () => {
signIn('editor');
vi.mocked(checkDriver).mockResolvedValue(missing);
render(modal());
expect((await screen.findByTestId('driver-install-needs-admin')).textContent).toMatch(/npm install redis/);
expect(screen.queryByTestId('conn-install-driver-package')).toBeNull();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ import {
passwordFieldLabel,
type ConnectionAuthMethod,
} from '@/shared/lib/provider-settings';
import type { DriverInfo } from '@/shared/lib/types';
import { IBM_DB_VERSION } from '@foxschema/ui-shared';
import { fetchSchemaList, checkDriver as apiCheckDriver, installDriver as apiInstallDriver } from "@/shared/api/schemaApi";
import { fetchSchemaList } from "@/shared/api/schemaApi";
import { driverStatusOf, installDriverFor, useDriverStatus } from '@/shared/lib/driverStatus';
import { DriverInstallHints } from '@/shared/components/DriverInstallHints';
import { useAuthStore } from '@/platform/authStore';
import { PasswordInput } from '@/shared/components/PasswordInput';
import { Autocomplete } from '@/shared/components/Autocomplete';
import { DatabaseFilePicker } from './DatabaseFilePicker';
Expand Down Expand Up @@ -92,8 +93,12 @@ export const ConnectionModal: React.FC<Props> = ({
const isFileDialect = isFileDialectOf(selDialect);

const [browsing, setBrowsing] = useState(false);
const [driverInfo, setDriverInfo] = useState<DriverInfo | null>(null);
const [installing, setInstalling] = useState(false);
// Shared with the SQL editor's banners: an install from either shows on both.
// Checked only while the form is open, as before.
const driver = useDriverStatus(open ? selDialect : '');
const driverInfo = driver.info;
const installing = driver.installing;
const canInstallDriver = useAuthStore((s) => s.can('admin.drivers'));

const [testingState, setTestingState] = useState<{
status: 'idle' | 'testing' | 'success' | 'failed';
Expand Down Expand Up @@ -144,18 +149,6 @@ export const ConnectionModal: React.FC<Props> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialOptionsKey is the stable snapshot
}, [open, dialect, initialName, initialHasPassword, initialOptionsKey]);

// Check whether the selected provider's driver is installed, whenever the
// modal opens or the provider changes. (Must stay above the early return —
// hooks run unconditionally every render.)
useEffect(() => {
if (!open) return;
let cancelled = false;
setDriverInfo(null);
apiCheckDriver(selDialect)
.then((info) => { if (!cancelled) setDriverInfo(info); })
.catch(() => { if (!cancelled) setDriverInfo(null); });
return () => { cancelled = true; };
}, [open, selDialect]);

if (!open) return null;

Expand Down Expand Up @@ -187,25 +180,9 @@ export const ConnectionModal: React.FC<Props> = ({
};

const handleInstall = async () => {
setInstalling(true);
setTestingState({ status: 'idle' });
try {
await apiInstallDriver(selDialect);
const info = await apiCheckDriver(selDialect);
setDriverInfo(info);
if (!info.installed) {
setTestingState({
status: 'failed',
error: info.error
? `Driver still not loading: ${info.error}`
: `Install finished but "${info.packageName}" still failed to load. Try: ${info.installCommand ?? `npm install ${info.packageName}`}`,
});
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Driver install failed';
setTestingState({ status: 'failed', error: message });
} finally {
setInstalling(false);
if (!(await installDriverFor(selDialect))) {
setTestingState({ status: 'failed', error: driverStatusOf(selDialect).installError });
}
};

Expand Down Expand Up @@ -387,45 +364,28 @@ export const ConnectionModal: React.FC<Props> = ({
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
Driver "{driverInfo.packageName}" is not installed
</span>
<button data-testid="conn-install-driver-package"
{canInstallDriver && <button data-testid="conn-install-driver-package"
onClick={handleInstall}
disabled={installing}
title="Install the driver package on the server"
className="shrink-0 text-xs font-bold on-accent-fg bg-amber-400 hover:bg-amber-300 disabled:bg-slate-700 disabled:text-slate-400 px-3 py-1 rounded flex items-center gap-1.5 cursor-pointer disabled:cursor-wait"
>
{installing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
{installing ? 'Installing…' : 'Install'}
</button>
</button>}
</div>
{installing && (
<div className="w-full h-1 rounded-full bg-amber-950/40 overflow-hidden">
<div className="w-1/3 h-full rounded-full bg-amber-400 animate-indeterminate-progress" />
</div>
)}
{!installing && driverInfo.error && (
<p className="text-[11px] text-amber-200/80 break-words font-mono leading-snug">
{driverInfo.error.slice(0, 280)}
{driverInfo.installCommand ? (
<>
{' '}
— run <span className="text-amber-100">{driverInfo.installCommand}</span>
</>
) : null}
</p>
)}
{!installing && !driverInfo.installed && driverInfo.packageName === 'ibm_db' && (
<p className="text-[11px] text-amber-200/70 leading-snug">
Db2 needs a native build + IBM clidriver download. Prefer{' '}
<span className="text-amber-100 font-mono">foxschema drivers install db2</span>
, then <span className="text-amber-100 font-mono">foxschema stop && foxschema</span>.
In a monorepo:{' '}
<span className="text-amber-100 font-mono">
npm install ibm_db@{IBM_DB_VERSION} --foreground-scripts -w @foxschema/db
</span>
. On linux/arm64 use Docker (
<span className="text-amber-100 font-mono">5nickels/foxschema:latest</span>).
</p>
)}
<DriverInstallHints
info={driverInfo}
packageName={driverInfo.packageName}
installing={installing}
installError=""
canInstall={canInstallDriver}
/>
</div>
)
)}
Expand Down
Loading
Loading