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
43 changes: 28 additions & 15 deletions apps/cli/src/commands/drivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,25 +126,38 @@ export async function runDriversInstall(name: string): Promise<void> {
const mono = monorepoRootFromWeb(webRoot);
const spec = entry.pin ? `${entry.pkg}@${entry.pin}` : entry.pkg;

// ibm_db must land where @foxschema/db can require it (not only apps/web).
const workspacePkg = entry.pkg === 'ibm_db' ? '@foxschema/db' : '@foxschema/web';
const prefixRoot =
entry.pkg === 'ibm_db'
? (() => {
try {
return dirname(require.resolve('@foxschema/db/package.json'));
} catch {
return webRoot;
}
})()
: webRoot;
// Drivers are required from @foxschema/db; installing into @foxschema/web
// leaves packages/db unable to resolve the module.
const workspacePkg = '@foxschema/db';
const prefixRoot = (() => {
try {
return dirname(require.resolve('@foxschema/db/package.json'));
} catch {
return webRoot;
}
})();

const cwd = mono ?? prefixRoot;
const version = entry.pin ?? 'latest';

// Prefer workspace install in a checkout; otherwise --prefix into the package tree.
// ibm_db must run install scripts or clidriver never downloads.
// Writing onto dependencies first: `npm install <peer> -w @foxschema/db` is a
// no-op when the package is already an optional peer.
if (mono) {
await new Promise<void>((resolve, reject) => {
const child = spawn('npm', ['pkg', 'set', `dependencies.${entry.pkg}=${version}`, '-w', workspacePkg], {
cwd: mono,
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('error', reject);
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`npm pkg set exited ${code}`))));
});
}
const npmArgs = mono
? ['install', spec, '--foreground-scripts', '-w', workspacePkg]
: ['install', spec, '--foreground-scripts', '--prefix', prefixRoot];
const cwd = mono ?? prefixRoot;
? ['install', '--foreground-scripts', '-w', workspacePkg]
: ['install', spec, '--save', '--foreground-scripts', '--prefix', prefixRoot];

await new Promise<void>((resolve, reject) => {
const child = spawn('npm', npmArgs, {
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/format/__tests__/friendlyError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ describe('friendlyError', () => {
expect(friendlyError(new Error('connect ETIMEDOUT 10.0.0.5:1433'))).toContain('timed out');
});

it('rewrites ECONNRESET', () => {
expect(friendlyError(new Error('read ECONNRESET'))).toMatch(/closed the connection before login \(ECONNRESET\)/);
});

it('rewrites Postgres auth failure', () => {
expect(friendlyError(new Error('password authentication failed for user "foo"'))).toBe(
'Login failed — check the username and password on this connection.'
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/src/format/friendlyError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const PATTERNS: Pattern[] = [
render: (m) => `Can't resolve host "${m[1]}" — check the hostname.`,
},
{ test: /ETIMEDOUT/i, render: () => 'Connection timed out — check the host, port, and any firewall/VPN.' },
{
test: /ECONNRESET/i,
render: () =>
'The database closed the connection before login (ECONNRESET) — often a paused cluster, firewall, TLS mismatch, or a local Docker database that is listening but not ready.',
},
{
test: /password authentication failed|access denied for user|login failed for user|ORA-01017|ORA-01005|SQLCODE=-1403|SQL30082/i,
render: () => 'Login failed — check the username and password on this connection.',
Expand Down
28 changes: 24 additions & 4 deletions apps/e2e/src/generated/test-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,20 @@ export const TestIds = {
/** div */
demoBanner: 'demo-banner',
},
DriverInventoryCard: {
/** section · Drivers */
homeDrivers: 'home-drivers',
/** p */
homeDriversActionError: 'home-drivers-action-error',
/** button */
homeDriversCheck: 'home-drivers-check',
/** p */
homeDriversError: 'home-drivers-error',
/** li */
homeDriversLoading: 'home-drivers-loading',
/** p */
homeDriversQueue: 'home-drivers-queue',
},
featureRegistry: {
/** data · Snapshots */
syncPaneHistoryBtn: 'sync-pane-history-btn',
Expand All @@ -773,13 +787,13 @@ export const TestIds = {
HomeView: {
/** button · Search workspaces and recents ⌘K */
homeCommandPalette: 'home-command-palette',
/** button */
homeConnection: (id: string | number) => `home-connection-${id}`,
/** section · Connections by provider */
homeConnections: 'home-connections',
/** span */
homeConnectionsCount: 'home-connections-count',
/** div */
/** span */
homeConnectionsCountForDialect: (dialect: string | number) => `home-connections-count-${dialect}`,
/** button */
homeConnectionsGroup: (dialect: string | number) => `home-connections-group-${dialect}`,
/** section */
homeContinue: 'home-continue',
Expand Down Expand Up @@ -4465,15 +4479,21 @@ export type TestId =
| `history-select-${string}`
| 'history-select-all'
| 'home-command-palette'
| `home-connection-${string}`
| 'home-connections'
| 'home-connections-count'
| `home-connections-count-${string}`
| `home-connections-group-${string}`
| 'home-continue'
| 'home-continue-snapshots'
| 'home-continue-sql'
| 'home-continue-sync'
| 'home-continue-utilities'
| 'home-drivers'
| 'home-drivers-action-error'
| 'home-drivers-check'
| 'home-drivers-error'
| 'home-drivers-loading'
| 'home-drivers-queue'
| 'home-open-btn'
| `home-recent-${string}`
| 'home-recents'
Expand Down
201 changes: 201 additions & 0 deletions apps/web/src/frontend/app/shell/DriverInventoryCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { DriverInventoryItem, DriverInventoryReport, Permission } from '@foxschema/shared';

const api = vi.hoisted(() => ({
list: (_latest?: boolean): Promise<DriverInventoryReport> => Promise.reject(new Error('not set')),
install: vi.fn(async (_packageName: string) => ({ success: true })),
update: vi.fn(async (_packageName: string) => ({ success: true })),
remove: vi.fn(async (_packageName: string) => ({ success: true })),
}));

vi.mock('@/shared/api/servicesApi', () => ({
fetchDriverInventory: (latest?: boolean) => api.list(latest),
installDriverPackage: (packageName: string) => api.install(packageName),
updateDriverPackage: (packageName: string) => api.update(packageName),
removeDriverPackage: (packageName: string) => api.remove(packageName),
}));

vi.mock('@/shared/api/schemaApi', () => ({
invalidateCache: () => {},
}));

import { useAuthStore } from '@/platform/authStore';
import { DriverInventoryCard, driverRowTestId } from './DriverInventoryCard';

const row = (over: Partial<DriverInventoryItem> & Pick<DriverInventoryItem, 'packageName'>): DriverInventoryItem => ({
dialects: ['mongodb'],
labels: ['MongoDB'],
installed: false,
updateAvailable: false,
bundled: false,
removable: false,
...over,
});

const report = (drivers: DriverInventoryItem[], latestChecked = false): DriverInventoryReport => ({
drivers,
checkedAt: 1,
latestChecked,
});

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

const text = (id: string) => screen.getByTestId(id).textContent;

describe('DriverInventoryCard', () => {
beforeEach(() => {
api.install.mockClear();
api.update.mockClear();
api.remove.mockClear();
signIn('admin');
api.list = async () =>
report([
row({
packageName: 'pg',
dialects: ['postgres', 'cockroachdb'],
labels: ['PostgreSQL', 'CockroachDB'],
installed: true,
version: '8.23.1',
bundled: false,
removable: true,
}),
row({ packageName: 'mongodb', installCommand: 'npm install mongodb' }),
]);
});

it('lists installed and missing drivers', async () => {
render(<DriverInventoryCard />);
await waitFor(() => expect(text(`${driverRowTestId('pg')}-state`)).toContain('Installed · 8.23.1'));
expect(text(`${driverRowTestId('mongodb')}-state`)).toBe('Not installed');
expect(screen.getByTestId(`${driverRowTestId('mongodb')}-install`)).toBeTruthy();
expect(screen.getByTestId(`${driverRowTestId('pg')}-remove`)).toBeTruthy();
});

it('offers no Install without admin.drivers', async () => {
signIn('editor');
render(<DriverInventoryCard />);
await waitFor(() => expect(screen.getByTestId(driverRowTestId('mongodb'))).toBeTruthy());
expect(screen.queryByTestId(`${driverRowTestId('mongodb')}-install`)).toBeNull();
});

it('installs a missing driver and refreshes the list', async () => {
let installed = false;
api.list = async () =>
report([
row({
packageName: 'mongodb',
installed,
version: installed ? '6.9.0' : undefined,
removable: installed,
}),
]);
api.install.mockImplementation(async () => {
installed = true;
return { success: true };
});
render(<DriverInventoryCard />);
await waitFor(() => expect(screen.getByTestId(`${driverRowTestId('mongodb')}-install`)).toBeTruthy());
fireEvent.click(screen.getByTestId(`${driverRowTestId('mongodb')}-install`));
await waitFor(() => expect(text(`${driverRowTestId('mongodb')}-state`)).toBe('Installed · 6.9.0'));
expect(api.install).toHaveBeenCalledWith('mongodb');
});

it('checks versions on first paint and offers Update when the registry is newer', async () => {
api.list = async (latest?: boolean) =>
report(
[
row({
packageName: 'mongodb',
installed: true,
version: '6.0.0',
latest: latest ? '6.9.0' : undefined,
updateAvailable: !!latest,
removable: true,
}),
],
!!latest
);
render(<DriverInventoryCard />);
await waitFor(() => expect(screen.getByTestId(`${driverRowTestId('mongodb')}-update`)).toBeTruthy());
expect(text(`${driverRowTestId('mongodb')}-state`)).toContain('6.0.0 → 6.9.0');
fireEvent.click(screen.getByTestId('home-drivers-check'));
await waitFor(() => expect(screen.getByTestId(`${driverRowTestId('mongodb')}-update`)).toBeTruthy());
});

it('queues a second driver while the first npm is still running', async () => {
let releaseInstall: (() => void) | undefined;
let pgUpdated = false;
api.list = async () =>
report([
row({ packageName: 'mongodb', installCommand: 'npm install mongodb' }),
row({
packageName: 'mssql',
dialects: ['sqlserver'],
labels: ['SQL Server'],
installed: true,
version: '12.7.13',
latest: '12.7.14',
updateAvailable: true,
removable: true,
}),
]);
api.install.mockImplementation(
() =>
new Promise((resolve) => {
releaseInstall = () => resolve({ success: true });
})
);
api.update.mockImplementation(async () => {
pgUpdated = true;
return { success: true };
});
render(<DriverInventoryCard />);
await waitFor(() => expect(screen.getByTestId(`${driverRowTestId('mongodb')}-install`)).toBeTruthy());
fireEvent.click(screen.getByTestId(`${driverRowTestId('mongodb')}-install`));
fireEvent.click(screen.getByTestId(`${driverRowTestId('mssql')}-update`));
await waitFor(() => expect(text(`${driverRowTestId('mssql')}-update`)).toContain('Waiting'));
expect(screen.getByTestId('home-drivers-queue')).toBeTruthy();
expect(api.update).not.toHaveBeenCalled();
expect(releaseInstall).toBeTypeOf('function');
releaseInstall!();
await waitFor(() => expect(api.update).toHaveBeenCalledWith('mssql'));
expect(pgUpdated).toBe(true);
await waitFor(() => expect(screen.queryByTestId('home-drivers-queue')).toBeNull());
});

it('asks once more before removing', async () => {
let gone = false;
api.list = async () =>
report([
row({
packageName: 'mongodb',
installed: !gone,
version: gone ? undefined : '6.9.0',
removable: !gone,
}),
]);
api.remove.mockImplementation(async () => {
gone = true;
return { success: true };
});
render(<DriverInventoryCard />);
await waitFor(() => expect(screen.getByTestId(`${driverRowTestId('mongodb')}-remove`)).toBeTruthy());
fireEvent.click(screen.getByTestId(`${driverRowTestId('mongodb')}-remove`));
expect(api.remove).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId(`${driverRowTestId('mongodb')}-remove-confirm`));
await waitFor(() => expect(text(`${driverRowTestId('mongodb')}-state`)).toBe('Not installed'));
expect(api.remove).toHaveBeenCalledWith('mongodb');
});
});
Loading
Loading