Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
48 changes: 48 additions & 0 deletions .github/workflows/endform-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Endform E2E

on:
pull_request:
workflow_dispatch:
inputs:
concurrency:
description: Maximum tests running at once
default: "64"

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
node:
name: Node E2E on Endform (production build)
runs-on: ubuntu-latest
timeout-minutes: 40
permissions:
contents: read
id-token: write
env:
EMDASH_ENDFORM_CONCURRENCY: ${{ inputs.concurrency || '64' }}
ENDFORM_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
ENDFORM_GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build fixture dependencies
run: pnpm run --filter "emdash-e2e-fixture..." build
- name: Run parallel E2E on Endform
id: parallel
run: pnpm exec endform test --organization-id 2G1ZCj7X --project chromium
- name: Run exclusive E2E on Endform
id: exclusive
if: ${{ !cancelled() }}
run: pnpm exec endform test --organization-id 2G1ZCj7X --project exclusive
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ uploads/
!packages/core/tests/integration/fixture/.emdash/
!e2e/fixture/.emdash/
!e2e/fixture-cloudflare/.emdash/
e2e/fixture*/.emdash/migrations.json
!templates/*/.emdash/seed.json

# macOS-specific files
Expand Down
70 changes: 70 additions & 0 deletions e2e/endform-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Long-running fixture server for Endform runs.
*
* Endform runs each test on a remote machine, so Playwright's globalSetup
* can't hand server info to tests through a local temp file. This process
* runs the normal global setup, then serves the server info on port 4446.
* Endform proxies loopback traffic from the runners back to this machine,
* where the `serverInfo` fixture fetches it.
*/
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 SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json");
const PORT = 4446;

await globalSetup();
// Endform only echoes a webServer's stderr.
console.error(
`[pw] Fixture ready (${process.env.EMDASH_E2E_BUILD === "production" ? "production build" : "astro dev"})`,
);

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(SERVER_INFO_PATH));
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(SERVER_INFO_PATH, "utf8"));
info.token = update.token;
writeFileSync(SERVER_INFO_PATH, JSON.stringify(info, null, 2));
response.end();
return;
}
response.writeHead(405).end();
});

server.listen(PORT, "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);
3 changes: 3 additions & 0 deletions e2e/fixture/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ export default defineConfig({
defaultLocale: "en",
locales: ["en", "fr", "es"],
fallback: { fr: "en", es: "en" },
// The fixture serves locales from its own `[locale]` routes. Automatic
// routing makes production builds add fallback routes that shadow them.
routing: "manual",
},
devToolbar: { enabled: false },
vite: {
Expand Down
3 changes: 2 additions & 1 deletion e2e/fixture/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"astro": "catalog:",
"emdash": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
"react-dom": "catalog:",
"sharp": "^0.35.4"
}
}
5 changes: 5 additions & 0 deletions e2e/fixture/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineMiddleware } from "astro:middleware";

// Manual i18n routing requires a middleware file; the fixture's `[locale]`
// routes handle locale prefixes themselves.
export const onRequest = defineMiddleware((_context, next) => next());
10 changes: 10 additions & 0 deletions e2e/fixture/src/outer-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { readFileSync } from "node:fs";

import { defineMiddleware } from "astro:middleware";
import { installRegistryAuthoritativeFixture } from "emdash/internal/testing/registry";
import { getRequestContext } from "emdash/request-context";

// astro.config.mjs installs the fixture for `astro dev`; a production server
// never runs the config, so install it here at startup.
const registryFixturePath = process.env.EMDASH_REGISTRY_FIXTURE;
if (!import.meta.env.DEV && registryFixturePath) {
installRegistryAuthoritativeFixture(JSON.parse(readFileSync(registryFixturePath, "utf8")));
}

const FINALIZER_NONCE = "outer-finalizer";

function preNextState(locals: App.Locals): string {
Expand Down
12 changes: 4 additions & 8 deletions e2e/fixtures/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,19 +441,15 @@ export class AdminPage {
// Click add link button
await this.page.getByRole("button", { name: "Add Custom Link" }).first().click();

// Wait for dialog to appear
await this.page.waitForSelector('[role="dialog"]', { state: "visible", timeout: 5000 });

// Fill form (scope to dialog to avoid ambiguity)
const dialog = this.page.locator('[role="dialog"]');
// Toasts also have the dialog role, so pick the form's dialog by its field.
const dialog = this.page.getByRole("dialog").filter({ has: this.page.getByLabel("URL") });
await expect(dialog).toBeVisible({ timeout: 5000 });
await dialog.getByLabel("Label").fill(label);
await dialog.getByLabel("URL").fill(url);

// Submit
await dialog.getByRole("button", { name: "Add" }).click();

// Wait for dialog to close
await this.page.waitForSelector('[role="dialog"]', { state: "hidden" });
await expect(dialog).toBeHidden();
}

/**
Expand Down
84 changes: 84 additions & 0 deletions e2e/fixtures/compression-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Gzip reverse proxy for the production-build fixture.
*
* Astro's standalone Node server sends responses uncompressed, while real
* deployments sit behind a CDN or proxy that compresses them. On Endform every
* test downloads the admin bundle through the CLI machine, so compressing it
* cuts the bytes on that path by about four times. Only built assets are
* compressed, so pages and API responses reach tests exactly as the server
* sent them.
*/
import { createServer, request, type OutgoingHttpHeaders, type Server } from "node:http";
import { createGzip } from "node:zlib";

const BUILT_ASSET_PATH = /^\/_astro\//;
const COMPRESSIBLE_TYPE = /^(text\/|application\/(javascript|json|xml)|image\/svg\+xml)/;
const ACCEPTS_GZIP = /\bgzip\b/;

interface CompressedAsset {
headers: OutgoingHttpHeaders;
body: Buffer;
}

export function startCompressionProxy(listenPort: number, targetPort: number): Promise<Server> {
// Built assets are content-hashed and never change while the server runs,
// so each one is compressed once instead of on every request.
const compressedAssets = new Map<string, CompressedAsset>();

const server = createServer((clientRequest, clientResponse) => {
const url = clientRequest.url ?? "";
const wantsGzip =
clientRequest.method === "GET" &&
BUILT_ASSET_PATH.test(url) &&
ACCEPTS_GZIP.test(String(clientRequest.headers["accept-encoding"] ?? ""));
const cached = wantsGzip ? compressedAssets.get(url) : undefined;
if (cached) {
clientResponse.writeHead(200, cached.headers);
clientResponse.end(cached.body);
return;
}

const upstream = request(
{
host: "127.0.0.1",
port: targetPort,
method: clientRequest.method,
path: url,
headers: clientRequest.headers,
},
(upstreamResponse) => {
const status = upstreamResponse.statusCode ?? 502;
const headers: OutgoingHttpHeaders = { ...upstreamResponse.headers };
const compress =
wantsGzip &&
!headers["content-encoding"] &&
COMPRESSIBLE_TYPE.test(String(headers["content-type"] ?? ""));
if (!compress) {
clientResponse.writeHead(status, headers);
upstreamResponse.pipe(clientResponse);
return;
}
delete headers["content-length"];
headers["content-encoding"] = "gzip";
headers.vary = headers.vary ? `${headers.vary}, Accept-Encoding` : "Accept-Encoding";
clientResponse.writeHead(status, headers);
const gzip = createGzip();
const chunks: Buffer[] = [];
gzip.on("data", (chunk: Buffer) => chunks.push(chunk));
gzip.on("end", () => {
if (status === 200) compressedAssets.set(url, { headers, body: Buffer.concat(chunks) });
});
upstreamResponse.pipe(gzip).pipe(clientResponse);
},
);
upstream.on("error", () => {
if (!clientResponse.headersSent) clientResponse.writeHead(502);
clientResponse.end();
});
clientRequest.pipe(upstream);
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(listenPort, "localhost", () => resolve(server));
});
}
36 changes: 36 additions & 0 deletions e2e/fixtures/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { ServerInfo } from "./index";

/**
* Create a draft post through the API and return its id. Tests that edit a
* post use one of their own, since other tests run against the same site.
*/
export async function createPost(serverInfo: ServerInfo, title: string): Promise<string> {
const response = await fetch(`${serverInfo.baseUrl}/_emdash/api/content/posts`, {
method: "POST",
headers: {
Authorization: `Bearer ${serverInfo.token}`,
"Content-Type": "application/json",
Origin: serverInfo.baseUrl,
"X-EmDash-Request": "1",
},
body: JSON.stringify({
data: {
title,
body: [
{
_type: "block",
_key: "intro",
style: "normal",
children: [{ _type: "span", _key: "intro-text", text: "Post body." }],
markDefs: [],
},
],
},
}),
});
if (!response.ok) throw new Error(`Creating post failed: ${response.status}`);
const body: { data?: { item?: { id?: unknown } } } = await response.json();
const id = body.data?.item?.id;
if (typeof id !== "string") throw new Error("Creating post returned no id");
return id;
}
18 changes: 17 additions & 1 deletion e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* 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";

Expand All @@ -17,6 +17,7 @@ import { AdminPage } from "./admin";
export { AdminPage } from "./admin";

const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json");
export const ENDFORM_SERVER_INFO_URL = "http://127.0.0.1:4446/server-info";

export interface ServerInfo {
pid: number;
Expand All @@ -40,7 +41,22 @@ function getServerInfo(): ServerInfo {
export const test = base.extend<{
admin: AdminPage;
serverInfo: ServerInfo;
endformServerInfo: void;
}>({
// On Endform the server runs on the CLI machine (see e2e/endform-server.ts);
// copy its server info into this runner's temp file before each test.
endformServerInfo: [
// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture dependencies
async ({}, use) => {
if (process.env.ENDFORM === "true") {
const response = await fetch(ENDFORM_SERVER_INFO_URL);
if (!response.ok) throw new Error(`Server info unavailable (${response.status})`);
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
10 changes: 10 additions & 0 deletions e2e/fixtures/refresh-server-pat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { ENDFORM_SERVER_INFO_URL } from "./index";

const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json");

export async function refreshServerPatAfterDevBypass(baseUrl: string): Promise<void> {
Expand All @@ -21,4 +23,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(ENDFORM_SERVER_INFO_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
if (!update.ok) throw new Error(`Server info update failed (${update.status})`);
}
}
Loading
Loading