Postcard app e2e front component rendering test (#21600)

# Introduction
Creating a playwright test, quite granular and verbose that will verify
that the post card preview front component is rendered as expected on
the tested twenty instance

This covers everything e2e from twenty front, front comp renderer,
assets cdn rendered redirection etc
Style bridge etc

## Note

The playwright test setup assumes the application has already been
installed once, it's mainly used by the merge queue as a high level
front component and logic function ( will be in the same ci ) regression
bottleneck
The goal isn't for this test to be run locally
This commit is contained in:
Paul Rastoin
2026-06-15 15:02:19 +02:00
committed by GitHub
parent 4be76e3fd1
commit d1ba63d4a4
9 changed files with 327 additions and 5 deletions
@@ -11,6 +11,10 @@ generated
# testing
/coverage
/e2e/.auth
/e2e/.results
/playwright-report
/test-results
# dev
/dist/
@@ -0,0 +1,68 @@
import { type Locator, expect, test as setup } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
const AUTH_DIR = path.resolve(__dirname, '.auth');
const STORAGE_STATE = path.join(AUTH_DIR, 'user.json');
const WORKSPACE_ORIGIN_FILE = path.join(AUTH_DIR, 'workspace-origin.txt');
const LOGIN = process.env.E2E_LOGIN ?? 'tim@apple.dev';
const PASSWORD = process.env.E2E_PASSWORD ?? 'tim@apple.dev';
const WORKSPACE_NAME = process.env.E2E_WORKSPACE_NAME ?? 'Apple';
const isVisible = async (locator: Locator) =>
locator.isVisible().catch(() => false);
setup('authenticate', async ({ page }) => {
await page.goto('/');
// A fresh load shows the auth provider choice even when credentials are prefilled.
const continueWithEmail = page.getByRole('button', {
name: 'Continue with Email',
});
const emailField = page.getByPlaceholder('Email');
// Wait on the concrete auth UI rather than networkidle (flaky per Playwright).
await expect(continueWithEmail.or(emailField).first()).toBeVisible();
if (await isVisible(continueWithEmail)) {
await continueWithEmail.click();
}
if (await isVisible(emailField)) {
await emailField.fill(LOGIN);
await page.getByRole('button', { name: 'Continue', exact: true }).click();
}
const passwordField = page.getByPlaceholder('Password');
await passwordField.waitFor({ state: 'visible' });
await passwordField.fill(PASSWORD);
const signInButton = page.getByRole('button', { name: 'Sign in' });
await expect(signInButton).toBeEnabled();
await signInButton.click();
// Multi-workspace logins land on a picker; single-workspace logins skip it.
const workspacePicker = page.getByText('Choose a workspace');
const reachedPicker = await workspacePicker
.waitFor({ state: 'visible', timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (reachedPicker) {
await page.getByText(WORKSPACE_NAME, { exact: true }).click();
await page.waitForFunction(() => window.location.href.includes('verify'));
await page.waitForFunction(() => !window.location.href.includes('verify'));
}
await page.waitForFunction(
() => window.localStorage.getItem('tokenPairState') !== null,
undefined,
{ timeout: 15_000 },
);
fs.mkdirSync(AUTH_DIR, { recursive: true });
fs.writeFileSync(WORKSPACE_ORIGIN_FILE, new URL(page.url()).origin);
await page.context().storageState({ path: STORAGE_STATE });
});
@@ -0,0 +1,129 @@
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);
}
});
});
@@ -16,16 +16,20 @@
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"twenty-client-sdk": "2.13.0",
"twenty-sdk": "2.13.0"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"playwright": "^1.60.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.9.3",
@@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test';
import * as path from 'path';
// Front-end base URL of the running Twenty instance under test.
const FRONT_BASE_URL = process.env.FRONT_BASE_URL ?? 'http://localhost:3001';
export default defineConfig({
testDir: './e2e',
outputDir: './e2e/.results',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
timeout: 60 * 1000,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: FRONT_BASE_URL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
headless: true,
testIdAttribute: 'data-testid',
},
expect: {
timeout: 15_000,
},
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: path.resolve(__dirname, 'e2e/.auth/user.json'),
},
dependencies: ['setup'],
},
],
});
@@ -0,0 +1,9 @@
// Shared between the front component and its e2e spec so the test ids cannot
// drift. Keep this module side-effect free so the Playwright test can import it
// without pulling in the component's SDK runtime dependencies.
export const CARD_TEST_IDS = {
root: 'postcard-card',
name: 'postcard-card-name',
status: 'postcard-card-status',
content: 'postcard-card-content',
} as const;
@@ -1,9 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useRecordId } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
import { CARD_TEST_IDS } from './card-test-ids';
export const CARD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'88c15ae2-5f87-4a6b-b48f-1974bbe62eb7';
@@ -30,7 +32,10 @@ const CardDisplay = ({
status: string;
}) => {
return (
<div style={{ padding: '24px', fontFamily: 'sans-serif' }}>
<div
data-testid={CARD_TEST_IDS.root}
style={{ padding: '24px', fontFamily: 'sans-serif' }}
>
<div
style={{
display: 'flex',
@@ -39,10 +44,14 @@ const CardDisplay = ({
marginBottom: '16px',
}}
>
<span style={{ fontSize: '15px', fontWeight: 600, color: '#333' }}>
<span
data-testid={CARD_TEST_IDS.name}
style={{ fontSize: '15px', fontWeight: 600, color: '#333' }}
>
{name || 'Untitled'}
</span>
<span
data-testid={CARD_TEST_IDS.status}
style={{
fontSize: '11px',
fontWeight: 600,
@@ -57,6 +66,7 @@ const CardDisplay = ({
</div>
<p
data-testid={CARD_TEST_IDS.content}
style={{
fontSize: '14px',
lineHeight: '1.6',
@@ -165,7 +175,7 @@ const PostCardPreview = () => {
<div style={{ marginTop: '8px', fontSize: '11px', color: '#ccc' }}>
recordId: {recordId ?? 'null'} | apiUrl:{' '}
{process.env.TWENTY_API_URL ? 'set' : 'missing'} | token:{' '}
{process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY
{(process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY)
? 'set'
: 'missing'}
</div>
@@ -30,6 +30,8 @@
"exclude": [
"node_modules",
"dist",
"e2e",
"playwright.config.ts",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
@@ -613,6 +613,17 @@ __metadata:
languageName: node
linkType: hard
"@playwright/test@npm:^1.60.0":
version: 1.60.0
resolution: "@playwright/test@npm:1.60.0"
dependencies:
playwright: "npm:1.60.0"
bin:
playwright: cli.js
checksum: 10c0/86b06e6437933e741c7cd43f362024e857e7bc28a55fcbb0553ef55e01a2a403c64f4786868de8af86a6e303fe99e98a18a42ba19489f43ae122e457f9e2d189
languageName: node
linkType: hard
"@rolldown/binding-android-arm64@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-android-arm64@npm:1.0.3"
@@ -1475,6 +1486,16 @@ __metadata:
languageName: node
linkType: hard
"fsevents@npm:2.3.2":
version: 2.3.2
resolution: "fsevents@npm:2.3.2"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b
conditions: os=darwin
languageName: node
linkType: hard
"fsevents@npm:~2.3.3":
version: 2.3.3
resolution: "fsevents@npm:2.3.3"
@@ -1485,6 +1506,15 @@ __metadata:
languageName: node
linkType: hard
"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>":
version: 2.3.2
resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>::version=2.3.2&hash=df0bf1"
dependencies:
node-gyp: "npm:latest"
conditions: os=darwin
languageName: node
linkType: hard
"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin<compat/fsevents>":
version: 2.3.3
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin<compat/fsevents>::version=2.3.3&hash=df0bf1"
@@ -2246,13 +2276,39 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.60.0":
version: 1.60.0
resolution: "playwright-core@npm:1.60.0"
bin:
playwright-core: cli.js
checksum: 10c0/99ccd43923b6e9355e0723b7fe221e6326efd4687f8dafff951313662aea11db51f542a9c2122c704c445fb9baae1c9ec9fa6f895126bbddd9fe92313f6942c9
languageName: node
linkType: hard
"playwright@npm:1.60.0, playwright@npm:^1.60.0":
version: 1.60.0
resolution: "playwright@npm:1.60.0"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.60.0"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10c0/714ad76d85b4865d7e43c0012f9039800c1485373388973ed39d79339cee5ad467052d1e2f1eaeca107a1cb6e65342186a8578a4c3504853d84c3a691250d5db
languageName: node
linkType: hard
"postcard@workspace:.":
version: 0.0.0-use.local
resolution: "postcard@workspace:."
dependencies:
"@playwright/test": "npm:^1.60.0"
"@types/node": "npm:^24.7.2"
"@types/react": "npm:^19.0.0"
oxlint: "npm:^0.16.0"
playwright: "npm:^1.60.0"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
twenty-client-sdk: "npm:2.13.0"