f04db9751f
## Problem A front component that imports `MetadataApiClient` from `twenty-client-sdk/metadata` crashes at render time: ``` FrontComponent error: Failed to resolve module specifier "./chunk-Dqa2HsxW.mjs". Invalid relative url or base scheme isn't hierarchical. ``` (hash differs per build). The equivalent component using `CoreApiClient` from `twenty-client-sdk/core` works fine. ## Root cause The front-component renderer loads each SDK client as a **single in-memory blob-URL module** and only rewrites the two bare specifiers it knows (`twenty-client-sdk/core`, `twenty-client-sdk/metadata`). A blob-URL module cannot resolve a **relative** `import … from "./chunk-*.mjs"` (blob URLs aren't hierarchical), and that chunk isn't served anyway. Only two entrypoints are externalized by the front-component build (`FRONT_COMPONENT_EXTERNAL_MODULES`) and thus served as blob modules: `core` and `metadata`. Everything else (`rest`, `generate`) is bundled into the component and is unaffected. Of those two: | client | how `dist/*.mjs` is produced | self-contained? | |---|---|---| | **core** | esbuild single-file bundle (`compileGeneratedClient`), re-run per workspace at server runtime by `replaceCoreClient` | ✅ | | **metadata** | the shared multi-entry Vite build, which hoists shared code into a relative `chunk-*.mjs` | ❌ | The metadata client is built once at package-build time (it is not workspace-specific) and was shipped straight from the multi-entry Vite output, keeping the unresolvable relative chunk import. ## Regression trace This was **not** broken on arrival — it regressed via a transitive bundler swap: | Date | Commit | Event | |---|---|---| | 2026-05-20 | `a26fe3bb65` | Metadata-client-in-front-components shipped; `twenty-client-sdk` on **Vite 7 (Rollup)** | | 2026-06-08 | `d2e7dc0e74` (#21309, *"security: bump vulnerable direct dependencies"*) | Bumped **Vite 7 → 8**, introducing **Rolldown 1.0.3** (no rolldown entries in the lockfile before this commit) | Vite 7 is Rollup-based; Vite 8 uses Rolldown. The breaking artifact is literally a `\0rolldown/runtime.js` shared chunk — a Rolldown construct that could not have existed before the bump. So the metadata front-component path worked from 2026-05-20 until the 2026-06-08 security dependency bump silently changed the bundler and split out the shared runtime chunk. ## Fix Build the metadata client as its **own single-entry Vite library** (`vite.metadata.config.ts`) so its output is a single self-contained file with no shared chunk. `core` / `rest` / `generate` stay in the main multi-entry build (`vite.config.ts`); shared config (`isExternal`, `entryFileNames`) is factored into `vite.shared.ts`. The build pipeline runs `vite build && vite build -c vite.metadata.config.ts`. The server picks this up automatically: `SdkClientGenerationService` ships the pre-built package `dist/` and only regenerates the **core** client; it never regenerates metadata. No server-side change required. ## Regression guard (e2e) The postcard example's `card.front-component.tsx` previously used `CoreApiClient` only, so this metadata-only regression had no e2e coverage. It now loads and round-trips all three SDK clients (`Core`, `Metadata`, `Rest`) via an SDK health panel, and the e2e asserts the blob-served `core` + `metadata` probes reach `ok` — which only happens if those bundles resolve and function. A future chunk-import regression in either blob module would crash the component on load and fail the test. ## Verification - `npx nx build twenty-client-sdk` succeeds. - `dist/metadata.mjs` / `dist/metadata.cjs`: **0** `chunk-*` imports, **0** relative imports; both load and export `MetadataApiClient` + `MetadataSchema`. - `dist/metadata/index.d.ts` types still emitted. - `npx nx typecheck` + `npx nx lint twenty-client-sdk` pass; postcard app typecheck + lint pass. ## Notes - `dist/` is not committed (CI builds it); a running server must rebuild `twenty-client-sdk` for the fix to take effect. - The e2e was validated statically (typecheck + lint); running it end-to-end requires a live stack with a seeded postcard record.
140 lines
4.6 KiB
TypeScript
140 lines
4.6 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
import { CARD_TEST_IDS } from '../src/components/card-test-ids';
|
|
|
|
// Seeded postcard record the preview should display.
|
|
const RECORD_ID = process.env.E2E_POSTCARD_RECORD_ID;
|
|
const EXPECTED_NAME = process.env.E2E_POSTCARD_NAME;
|
|
const EXPECTED_STATUS = process.env.E2E_POSTCARD_STATUS;
|
|
const EXPECTED_CONTENT = process.env.E2E_POSTCARD_CONTENT;
|
|
|
|
const STATUS_BADGE_BACKGROUND: Record<string, string> = {
|
|
DRAFT: 'rgb(153, 153, 153)',
|
|
SENT: 'rgb(232, 140, 48)',
|
|
DELIVERED: 'rgb(76, 175, 80)',
|
|
RETURNED: 'rgb(224, 82, 82)',
|
|
};
|
|
|
|
const WORKSPACE_ORIGIN_FILE = path.resolve(
|
|
__dirname,
|
|
'.auth',
|
|
'workspace-origin.txt',
|
|
);
|
|
|
|
const resolveWorkspaceUrl = (): string => {
|
|
const fromEnv = process.env.E2E_WORKSPACE_URL;
|
|
if (fromEnv) {
|
|
return fromEnv.replace(/\/$/, '');
|
|
}
|
|
|
|
try {
|
|
return fs
|
|
.readFileSync(WORKSPACE_ORIGIN_FILE, 'utf8')
|
|
.trim()
|
|
.replace(/\/$/, '');
|
|
} catch {
|
|
return 'http://app.localhost:3001';
|
|
}
|
|
};
|
|
|
|
// Error states rendered by card.front-component.tsx when it cannot authenticate
|
|
// or fetch the record. None of these may appear once the component renders.
|
|
const FALLBACK_TEXTS = [
|
|
'No postcard data',
|
|
'Record not found',
|
|
'No record ID',
|
|
'apiUrl: missing',
|
|
];
|
|
|
|
test.describe('Postcard card front component', () => {
|
|
test.beforeAll(() => {
|
|
if (!RECORD_ID) {
|
|
throw new Error(
|
|
'E2E_POSTCARD_RECORD_ID is required and must point to a seeded postcard record. ' +
|
|
'Ensure the postcard app is installed and a record exists before running this test.',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('renders the postcard name and status badge in the record preview', async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(`${resolveWorkspaceUrl()}/object/postCard/${RECORD_ID}`);
|
|
|
|
const card = page.getByTestId(CARD_TEST_IDS.root);
|
|
await expect(card).toBeVisible();
|
|
|
|
const cardName = card.getByTestId(CARD_TEST_IDS.name);
|
|
const cardStatus = card.getByTestId(CARD_TEST_IDS.status);
|
|
const cardContent = card.getByTestId(CARD_TEST_IDS.content);
|
|
|
|
await expect(cardName).toHaveCount(1);
|
|
await expect(cardStatus).toHaveCount(1);
|
|
await expect(cardContent).toHaveCount(1);
|
|
|
|
if (EXPECTED_NAME) {
|
|
await expect(cardName).toHaveText(EXPECTED_NAME);
|
|
}
|
|
|
|
if (EXPECTED_STATUS) {
|
|
await expect(cardStatus).toHaveText(EXPECTED_STATUS);
|
|
}
|
|
|
|
if (EXPECTED_CONTENT) {
|
|
await expect(cardContent).toHaveText(EXPECTED_CONTENT);
|
|
}
|
|
|
|
// Redundant style assertions: the front component sets every style inline, so
|
|
// verifying the computed styles proves the component's own render + the
|
|
// remote-dom style bridge ran end-to-end (not just that text leaked onto the
|
|
// page). These mirror card.front-component.tsx exactly.
|
|
|
|
// Root container.
|
|
await expect(card).toHaveCSS('padding', '24px');
|
|
|
|
// Name.
|
|
await expect(cardName).toHaveCSS('font-size', '15px');
|
|
await expect(cardName).toHaveCSS('font-weight', '600');
|
|
await expect(cardName).toHaveCSS('color', 'rgb(51, 51, 51)');
|
|
|
|
// Status badge: white text on a status-dependent colored, rounded chip.
|
|
await expect(cardStatus).toHaveCSS('color', 'rgb(255, 255, 255)');
|
|
await expect(cardStatus).toHaveCSS('font-size', '11px');
|
|
await expect(cardStatus).toHaveCSS('font-weight', '600');
|
|
await expect(cardStatus).toHaveCSS('border-radius', '4px');
|
|
await expect(cardStatus).toHaveCSS('padding-top', '2px');
|
|
await expect(cardStatus).toHaveCSS('padding-bottom', '2px');
|
|
await expect(cardStatus).toHaveCSS('padding-left', '8px');
|
|
await expect(cardStatus).toHaveCSS('padding-right', '8px');
|
|
|
|
if (EXPECTED_STATUS && EXPECTED_STATUS in STATUS_BADGE_BACKGROUND) {
|
|
await expect(cardStatus).toHaveCSS(
|
|
'background-color',
|
|
STATUS_BADGE_BACKGROUND[EXPECTED_STATUS],
|
|
);
|
|
}
|
|
|
|
// Content.
|
|
await expect(cardContent).toHaveCSS('font-size', '14px');
|
|
await expect(cardContent).toHaveCSS('color', 'rgb(85, 85, 85)');
|
|
await expect(cardContent).toHaveCSS('margin', '0px');
|
|
await expect(cardContent).toHaveCSS('white-space', 'pre-line');
|
|
|
|
for (const fallback of FALLBACK_TEXTS) {
|
|
await expect(page.getByText(fallback, { exact: false })).toHaveCount(0);
|
|
}
|
|
|
|
const sdkPanel = page.getByTestId(CARD_TEST_IDS.sdkPanel);
|
|
await expect(sdkPanel).toBeVisible();
|
|
|
|
await expect(page.getByTestId(CARD_TEST_IDS.sdkCore)).toHaveText('core: ok');
|
|
await expect(page.getByTestId(CARD_TEST_IDS.sdkMetadata)).toHaveText(
|
|
'metadata: ok',
|
|
);
|
|
|
|
await expect(page.getByTestId(CARD_TEST_IDS.sdkRest)).toHaveText('rest: ok');
|
|
});
|
|
});
|