Files
twenty/packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts
T
Paul Rastoin f04db9751f fix(client-sdk): bundle metadata client into a single self-contained file (#22085)
## 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.
2026-06-25 10:19:18 +02:00

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');
});
});