Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 21 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -440,36 +446,36 @@ 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/
retention-days: 7
- 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/
Expand Down
56 changes: 56 additions & 0 deletions e2e/endform-server.ts
Original file line number Diff line number Diff line change
@@ -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);
25 changes: 24 additions & 1 deletion e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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());
Expand Down
8 changes: 8 additions & 0 deletions e2e/fixtures/refresh-server-pat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,12 @@ export async function refreshServerPatAfterDevBypass(baseUrl: string): Promise<v
const info = JSON.parse(readFileSync(SERVER_INFO_PATH, "utf-8"));
info.token = token;
writeFileSync(SERVER_INFO_PATH, JSON.stringify(info, null, 2));
if (process.env.ENDFORM === "true") {
const update = await fetch("http://localhost:4446/server-info", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
if (!update.ok) throw new Error("Unable to update fixture server state");
}
}
22 changes: 22 additions & 0 deletions e2e/fixtures/warm-up-admin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* @param {import("@playwright/test").Page} page
* @param {string} baseUrl
*/
export async function warmUpAdmin(page, baseUrl) {
// 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.waitForSelector("h1", { 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);
}
}
}
37 changes: 11 additions & 26 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";

import { warmUpAdmin } from "./fixtures/warm-up-admin.js";

const execAsync = promisify(execFile);

const __filename = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -291,31 +293,6 @@ async function seedTestData(
};
}

async function warmUpAdmin(baseUrl: string): Promise<void> {
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -429,7 +406,15 @@ export default async function globalSetup(): Promise<void> {
// 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 = {
Expand Down
1 change: 1 addition & 0 deletions e2e/tests/content-crud.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
7 changes: 7 additions & 0 deletions endform.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineEndformConfig } from "endform";

export default defineEndformConfig({
additionalFiles: ["e2e/fixtures/assets/test-image.png"],
proxyNetworkHosts: ["<loopback>"],
concurrentTestLimits: [{ scope: "within-suite-run", limit: 1 }],
});
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@
"@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",
"oxlint-tsgolint": "^0.25.0",
"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",
Expand Down
24 changes: 16 additions & 8 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { defineConfig, devices } from "@playwright/test";

const isEndform = process.env.ENDFORM === "true";

/**
* Playwright E2E test configuration for EmDash CMS
*
Expand All @@ -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,
Expand All @@ -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",
},

Expand All @@ -38,6 +48,4 @@ export default defineConfig({
use: { ...devices["Desktop Chrome"] },
},
],

// No webServer — global-setup.ts handles server lifecycle
});
Loading
Loading