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"