diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6230ee4996..1de4393895 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -412,13 +412,19 @@ jobs: - run: echo "E2E results are aggregated by the Tests job" test-e2e: - name: E2E shard (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) + name: E2E Endform shard (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) needs: change-plan if: needs.change-plan.outputs.e2e_node == 'true' || needs.change-plan.outputs.e2e_cloudflare == 'true' runs-on: ubuntu-latest - timeout-minutes: 40 + timeout-minutes: 90 + permissions: + contents: read + id-token: write + env: + ENDFORM_GIT_BRANCH: ${{ github.head_ref || github.ref_name }} + ENDFORM_GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} strategy: - fail-fast: true + fail-fast: false matrix: shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] shardTotal: [8] @@ -440,22 +446,18 @@ jobs: id: cloudflare-build if: ${{ !cancelled() && needs.change-plan.outputs.e2e_cloudflare == 'true' }} run: pnpm run --filter "emdash-e2e-fixture-cloudflare..." build - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - if: ${{ !cancelled() && (steps.node-build.outcome == 'success' || steps.cloudflare-build.outcome == 'success') }} - id: playwright-cache - with: - path: ~/.cache/ms-playwright - key: playwright-${{ hashFiles('pnpm-lock.yaml') }} - - run: pnpm exec playwright install --with-deps chromium - if: ${{ !cancelled() && (steps.node-build.outcome == 'success' || steps.cloudflare-build.outcome == 'success') && steps.playwright-cache.outputs.cache-hit != 'true' }} - name: Run Node E2E id: node-e2e if: ${{ !cancelled() && steps.node-build.outcome == 'success' }} - run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: | + ENDFORM=false pnpm exec playwright test --list --reporter=list --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} > "$RUNNER_TEMP/emdash-test-list.txt" + options=() + if [ "${{ matrix.shardIndex }}" = "1" ]; then options+=(--max-failures 1); fi + npx endform@latest test --organization-id 2G1ZCj7X --test-list "$RUNNER_TEMP/emdash-test-list.txt" "${options[@]}" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() && steps.node-e2e.outcome == 'failure' with: - name: playwright-report-node-${{ matrix.shardIndex }} + name: playwright-report-node-endform-${{ matrix.shardIndex }} path: | playwright-report/ test-results/ @@ -463,13 +465,17 @@ jobs: - name: Run Cloudflare E2E id: cloudflare-e2e if: ${{ !cancelled() && steps.cloudflare-build.outcome == 'success' }} - run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: | + ENDFORM=false pnpm exec playwright test --list --reporter=list --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} > "$RUNNER_TEMP/emdash-test-list.txt" + options=() + if [ "${{ matrix.shardIndex }}" = "1" ]; then options+=(--max-failures 1); fi + npx endform@latest test --organization-id 2G1ZCj7X --test-list "$RUNNER_TEMP/emdash-test-list.txt" "${options[@]}" env: EMDASH_E2E_TARGET: cloudflare - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() && steps.cloudflare-e2e.outcome == 'failure' with: - name: playwright-report-cloudflare-${{ matrix.shardIndex }} + name: playwright-report-cloudflare-endform-${{ matrix.shardIndex }} path: | playwright-report/ test-results/ diff --git a/e2e/endform-server.ts b/e2e/endform-server.ts new file mode 100644 index 0000000000..231552e8fe --- /dev/null +++ b/e2e/endform-server.ts @@ -0,0 +1,56 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import globalSetup from "./global-setup.js"; +import globalTeardown from "./global-teardown.js"; + +const serverInfoPath = join(tmpdir(), "emdash-pw-server.json"); + +await globalSetup(); + +const server = createServer(async (request, response) => { + if (request.url === "/ready" && request.method === "GET") { + response.end("ready"); + return; + } + if (request.url !== "/server-info") { + response.writeHead(404).end(); + return; + } + if (request.method === "GET") { + response.setHeader("Content-Type", "application/json"); + response.end(readFileSync(serverInfoPath)); + return; + } + if (request.method === "POST") { + let body = ""; + for await (const chunk of request) body += chunk; + const update: { token?: unknown } = JSON.parse(body); + if (typeof update.token !== "string") { + response.writeHead(400).end(); + return; + } + const info = JSON.parse(readFileSync(serverInfoPath, "utf8")); + info.token = update.token; + writeFileSync(serverInfoPath, JSON.stringify(info)); + response.end(); + return; + } + response.writeHead(405).end(); +}); + +server.listen(4446, "127.0.0.1"); + +let stopping = false; +async function stop() { + if (stopping) return; + stopping = true; + server.close(); + await globalTeardown(); + process.exit(0); +} + +process.on("SIGINT", stop); +process.on("SIGTERM", stop); diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts index a17c004a49..d84e458a5b 100644 --- a/e2e/fixtures/index.ts +++ b/e2e/fixtures/index.ts @@ -6,13 +6,14 @@ * the AdminPage helper and server context to each test. */ -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test as base } from "@playwright/test"; import { AdminPage } from "./admin"; +import { warmUpAdmin } from "./warm-up-admin.js"; export { AdminPage } from "./admin"; @@ -40,7 +41,29 @@ function getServerInfo(): ServerInfo { export const test = base.extend<{ admin: AdminPage; serverInfo: ServerInfo; + _endformServerState: void; }>({ + page: [ + async ({ page }, use) => { + if (process.env.ENDFORM === "true") { + await warmUpAdmin(page, process.env.BASE_URL || "http://localhost:4444"); + } + await use(page); + }, + { timeout: 240_000 }, + ], + _endformServerState: [ + // oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture dependencies + async ({}, use) => { + if (process.env.ENDFORM === "true") { + const response = await fetch("http://localhost:4446/server-info"); + if (!response.ok) throw new Error("Unable to load fixture server state"); + writeFileSync(SERVER_INFO_PATH, await response.text()); + } + await use(); + }, + { auto: true }, + ], // eslint-disable-next-line no-empty-pattern serverInfo: async ({}, use) => { await use(getServerInfo()); diff --git a/e2e/fixtures/refresh-server-pat.ts b/e2e/fixtures/refresh-server-pat.ts index 8fb56d026d..914eb535c8 100644 --- a/e2e/fixtures/refresh-server-pat.ts +++ b/e2e/fixtures/refresh-server-pat.ts @@ -21,4 +21,12 @@ export async function refreshServerPatAfterDevBypass(baseUrl: string): Promise { - const { chromium } = await import("@playwright/test"); - const browser = await chromium.launch(); - try { - const page = await browser.newPage(); - // The second load starts after any optimizer reload from the first, so - // its hydration means the dependency set has settled. - for (let load = 0; load < 2; load++) { - try { - await page.goto(`${baseUrl}/_emdash/admin/login`, { - waitUntil: "commit", - timeout: 120_000, - }); - await page.waitForSelector("astro-island:not([ssr])", { timeout: 120_000 }); - await page.waitForLoadState("networkidle", { timeout: 60_000 }); - } catch (error) { - // Non-fatal: a cold first admin test can still pass on retry. - console.warn(`[pw] Admin warm-up load ${load + 1} failed:`, error); - } - } - } finally { - await browser.close(); - } -} - // --------------------------------------------------------------------------- // Global setup // --------------------------------------------------------------------------- @@ -429,7 +406,15 @@ export default async function globalSetup(): Promise { // discovered when a browser requests them, and the optimizer then forces // a full reload that would otherwise land inside the first admin test. console.log("[pw] Warming up admin..."); - await warmUpAdmin(baseUrl); + if (process.env.ENDFORM !== "true") { + const { chromium } = await import("@playwright/test"); + const browser = await chromium.launch(); + try { + await warmUpAdmin(await browser.newPage(), baseUrl); + } finally { + await browser.close(); + } + } // 6. Write server info const info = { diff --git a/e2e/tests/content-crud.spec.ts b/e2e/tests/content-crud.spec.ts index cec6e45120..7e7fcb598e 100644 --- a/e2e/tests/content-crud.spec.ts +++ b/e2e/tests/content-crud.spec.ts @@ -19,6 +19,7 @@ const CONTENT_ID_PATTERN = /\/content\/posts\/[A-Z0-9]+(?:\?.*)?$/; const NEW_CONTENT_URL_PATTERN = /\/content\/posts\/new(?:[?#].*)?$/; test.describe("Content CRUD", () => { + test.describe.configure({ mode: "serial" }); test.beforeEach(async ({ admin }) => { await admin.devBypassAuth(); }); diff --git a/endform.config.ts b/endform.config.ts new file mode 100644 index 0000000000..564778f1a5 --- /dev/null +++ b/endform.config.ts @@ -0,0 +1,7 @@ +import { defineEndformConfig } from "endform"; + +export default defineEndformConfig({ + additionalFiles: ["e2e/fixtures/assets/test-image.png"], + proxyNetworkHosts: [""], + concurrentTestLimits: [{ scope: "within-suite-run", limit: 1 }], +}); diff --git a/package.json b/package.json index d44c2e9d82..3933a9fe90 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@types/node": "catalog:", "@typescript/native-preview": "7.0.0-dev.20260421.2", "emdash": "workspace:*", + "endform": "0.81.3", "knip": "^5.84.1", "oxfmt": "^0.59.0", "oxlint": "^1.74.0", @@ -57,6 +58,7 @@ "pkg-pr-new": "^0.0.78", "prettier": "^3.9.5", "prettier-plugin-astro": "^0.14.1", + "tsx": "4.21.0", "typescript": "6.0.0-beta" }, "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", diff --git a/playwright.config.ts b/playwright.config.ts index 62ba752804..eb2cd0df9f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; +const isEndform = process.env.ENDFORM === "true"; + /** * Playwright E2E test configuration for EmDash CMS * @@ -11,8 +13,8 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./e2e/tests", testIgnore: "portable-text-table.spec.ts", - // Disable parallel to avoid shared database state issues - fullyParallel: false, + // Endform limits concurrency around the shared fixture in endform.config.ts. + fullyParallel: isEndform, workers: 1, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, @@ -23,12 +25,20 @@ export default defineConfig({ // first hit; give it headroom so cold compilation doesn't time out specs. timeout: process.env.EMDASH_E2E_TARGET === "cloudflare" ? 90_000 : 30000, - globalSetup: "./e2e/global-setup.ts", - globalTeardown: "./e2e/global-teardown.ts", + globalSetup: isEndform ? undefined : "./e2e/global-setup.ts", + globalTeardown: isEndform ? undefined : "./e2e/global-teardown.ts", + webServer: isEndform + ? { + command: "pnpm exec tsx e2e/endform-server.ts", + url: "http://127.0.0.1:4446/ready", + reuseExistingServer: false, + timeout: 240_000, + } + : undefined, use: { - baseURL: "http://localhost:4444", - trace: "on-first-retry", + baseURL: process.env.BASE_URL || "http://localhost:4444", + trace: "retain-on-failure", screenshot: "only-on-failure", }, @@ -38,6 +48,4 @@ export default defineConfig({ use: { ...devices["Desktop Chrome"] }, }, ], - - // No webServer — global-setup.ts handles server lifecycle }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e03384404..b3c03097af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -374,6 +374,9 @@ importers: emdash: specifier: workspace:* version: link:packages/core + endform: + specifier: 0.81.3 + version: 0.81.3 knip: specifier: ^5.84.1 version: 5.84.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.10.13)(typescript@6.0.0-beta) @@ -395,6 +398,9 @@ importers: prettier-plugin-astro: specifier: ^0.14.1 version: 0.14.1 + tsx: + specifier: 4.21.0 + version: 4.21.0 typescript: specifier: 6.0.0-beta version: 6.0.0-beta @@ -13265,6 +13271,41 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + endform-darwin-arm64@0.81.3: + resolution: {integrity: sha512-BV3Ji195mR8GnxTbz+FDBS2wPKymW0o/xzxGz4TbWU0IdD4FLLsx4TZBm/VVEvkrnHnxXeJQhAaN2L+m4YTHVw==} + cpu: [arm64] + os: [darwin] + + endform-darwin-x86@0.81.3: + resolution: {integrity: sha512-w9/t/7X09tzGN5CQMISWXPYr5axRbqbAwjSTq0MWwZCF+sq25SjbSppllqzfCGAgieHJdmiMft6/RffRzAlmcg==} + cpu: [x64] + os: [darwin] + + endform-linux-arm64@0.81.3: + resolution: {integrity: sha512-YR0FMjgx66Y3IwX/8ZvBtooqTKt05GwHjKKuc14raYK+0c3qxArjP/jU/eyjhAc3ybd8YQ9jcTQjszvc8cf9OA==} + cpu: [arm64] + os: [linux] + + endform-linux-x86@0.81.3: + resolution: {integrity: sha512-TVahPFdCvq2dbyO7WtUjcbmHLNT+dXKB1YImEQ7gDXTLpv9AOigpkN8jYmK1SXgNHiwpibM7dGQYULOLw+oEDg==} + cpu: [x64] + os: [linux] + + endform-win32-arm64@0.81.3: + resolution: {integrity: sha512-etfYsOPqesPE1RIxzHKBPy/aICoFcCd1rCkPUw3xRBMZsCwYDHMFC/awrRN8d7Bav98T6ht4jyI8k07tsbVruQ==} + cpu: [arm64] + os: [win32] + + endform-win32-x86@0.81.3: + resolution: {integrity: sha512-2Oz2ys88AS5GbuyUvpUq5KdtPIv6I0DxRiwf+mWd/Onh2diPoTGD/cfZyU3GYF3GYtWFXmc7OKbEOeZiVeDRDw==} + cpu: [x64] + os: [win32] + + endform@0.81.3: + resolution: {integrity: sha512-VnlmxakSs1u7N1CjOp1hRtCDcBnmNwUH9UALAoxG2LRU3fWpUJ5lNw0s7cARqZTn5OM9F0iJYBvnlaRrOICApw==} + hasBin: true + + snapshots: '@adraffy/ens-normalize@1.11.1': {} @@ -24507,3 +24548,30 @@ snapshots: tslib: 2.3.0 zwitch@2.0.4: {} + + endform-darwin-arm64@0.81.3: + optional: true + + endform-darwin-x86@0.81.3: + optional: true + + endform-linux-arm64@0.81.3: + optional: true + + endform-linux-x86@0.81.3: + optional: true + + endform-win32-arm64@0.81.3: + optional: true + + endform-win32-x86@0.81.3: + optional: true + + endform@0.81.3: + optionalDependencies: + endform-darwin-arm64: 0.81.3 + endform-darwin-x86: 0.81.3 + endform-linux-arm64: 0.81.3 + endform-linux-x86: 0.81.3 + endform-win32-arm64: 0.81.3 + endform-win32-x86: 0.81.3