From d1ba63d4a46d10045d4e9952b04b0991ec58bb3a Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:02:19 +0200 Subject: [PATCH] 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 --- .../twenty-apps/examples/postcard/.gitignore | 4 + .../examples/postcard/e2e/auth.setup.ts | 68 +++++++++ .../postcard/e2e/card-front-component.spec.ts | 129 ++++++++++++++++++ .../examples/postcard/package.json | 6 +- .../examples/postcard/playwright.config.ts | 40 ++++++ .../postcard/src/components/card-test-ids.ts | 9 ++ .../src/components/card.front-component.tsx | 18 ++- .../examples/postcard/tsconfig.json | 2 + .../twenty-apps/examples/postcard/yarn.lock | 56 ++++++++ 9 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-apps/examples/postcard/e2e/auth.setup.ts create mode 100644 packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts create mode 100644 packages/twenty-apps/examples/postcard/playwright.config.ts create mode 100644 packages/twenty-apps/examples/postcard/src/components/card-test-ids.ts diff --git a/packages/twenty-apps/examples/postcard/.gitignore b/packages/twenty-apps/examples/postcard/.gitignore index 6cd0610624..3ce82946ba 100644 --- a/packages/twenty-apps/examples/postcard/.gitignore +++ b/packages/twenty-apps/examples/postcard/.gitignore @@ -11,6 +11,10 @@ generated # testing /coverage +/e2e/.auth +/e2e/.results +/playwright-report +/test-results # dev /dist/ diff --git a/packages/twenty-apps/examples/postcard/e2e/auth.setup.ts b/packages/twenty-apps/examples/postcard/e2e/auth.setup.ts new file mode 100644 index 0000000000..637ed3f5b0 --- /dev/null +++ b/packages/twenty-apps/examples/postcard/e2e/auth.setup.ts @@ -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 }); +}); diff --git a/packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts b/packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts new file mode 100644 index 0000000000..6e3142a2ce --- /dev/null +++ b/packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts @@ -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 = { + 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); + } + }); +}); diff --git a/packages/twenty-apps/examples/postcard/package.json b/packages/twenty-apps/examples/postcard/package.json index b28fbecac4..1f1d99fc28 100644 --- a/packages/twenty-apps/examples/postcard/package.json +++ b/packages/twenty-apps/examples/postcard/package.json @@ -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", diff --git a/packages/twenty-apps/examples/postcard/playwright.config.ts b/packages/twenty-apps/examples/postcard/playwright.config.ts new file mode 100644 index 0000000000..e74fc22876 --- /dev/null +++ b/packages/twenty-apps/examples/postcard/playwright.config.ts @@ -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'], + }, + ], +}); diff --git a/packages/twenty-apps/examples/postcard/src/components/card-test-ids.ts b/packages/twenty-apps/examples/postcard/src/components/card-test-ids.ts new file mode 100644 index 0000000000..acb5361b43 --- /dev/null +++ b/packages/twenty-apps/examples/postcard/src/components/card-test-ids.ts @@ -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; diff --git a/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx b/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx index 4dd93b41f5..a9501c36d6 100644 --- a/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx +++ b/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx @@ -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 ( -
+
- + {name || 'Untitled'}

{

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'}
diff --git a/packages/twenty-apps/examples/postcard/tsconfig.json b/packages/twenty-apps/examples/postcard/tsconfig.json index d574c8c810..27f0c39cc1 100644 --- a/packages/twenty-apps/examples/postcard/tsconfig.json +++ b/packages/twenty-apps/examples/postcard/tsconfig.json @@ -30,6 +30,8 @@ "exclude": [ "node_modules", "dist", + "e2e", + "playwright.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.integration-test.ts" diff --git a/packages/twenty-apps/examples/postcard/yarn.lock b/packages/twenty-apps/examples/postcard/yarn.lock index 385dc109ef..5b0e54cd1a 100644 --- a/packages/twenty-apps/examples/postcard/yarn.lock +++ b/packages/twenty-apps/examples/postcard/yarn.lock @@ -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": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::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": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::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"