From 56806281e036742858216b68639b5915e012ba67 Mon Sep 17 00:00:00 2001 From: adelnizamutdinov Date: Mon, 28 Sep 2026 02:06:31 +0300 Subject: [PATCH 1/3] Use Moon Node tasks and one Compose-owned Playwright browser --- .github/workflows/ci.yaml | 1 - .moon/tasks/all.yml | 6 +- .moon/toolchains.yml | 11 ++ .moon/workspace.yml | 1 + AGENTS.md | 2 +- README.md | 32 +++++- compose/Dockerfile | 15 +++ compose/Dockerfile.dockerignore | 4 + compose/browser-server.ts | 27 +++++ compose/docker-compose.yaml | 24 ++++ compose/moon.yml | 13 +++ compose/playwright.ts | 32 ++++++ e2e/browser-service.playwright.ts | 183 ++++++++++++++++++++++++++++++ moon.yml | 30 +++-- nub.lock | 70 ++++++++---- package.json | 4 +- playwright.config.ts | 2 + tsconfig.playwright.json | 2 +- 18 files changed, 418 insertions(+), 41 deletions(-) create mode 100644 .moon/toolchains.yml create mode 100644 compose/Dockerfile create mode 100644 compose/Dockerfile.dockerignore create mode 100644 compose/browser-server.ts create mode 100644 compose/docker-compose.yaml create mode 100644 compose/moon.yml create mode 100644 compose/playwright.ts create mode 100644 e2e/browser-service.playwright.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1ef9173..1db844c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,6 @@ jobs: with: moon-version: "2.5.5" - run: nub install --frozen-lockfile - - run: nubx playwright install --with-deps chromium - name: Check affected tasks id: ci run: moon ci diff --git a/.moon/tasks/all.yml b/.moon/tasks/all.yml index fc0627b..32f0ad8 100644 --- a/.moon/tasks/all.yml +++ b/.moon/tasks/all.yml @@ -1,6 +1,8 @@ implicitInputs: - moon.yml - - package.json - - nub.lock + - /package.json + - /nub.lock + - glob: /.node-version + - glob: /nub.jsonc - /.moon/**/*.yml - /.github/workflows/ci.yaml diff --git a/.moon/toolchains.yml b/.moon/toolchains.yml new file mode 100644 index 0000000..6535672 --- /dev/null +++ b/.moon/toolchains.yml @@ -0,0 +1,11 @@ +# Installation and Node selection belong to Nub and the workspace setup. +# Share package-aware task execution with workspaces consuming this repository. +javascript: + packageManager: nub + inferTasksFromScripts: false + syncPackageManagerField: false + syncProjectWorkspaceDependencies: false +node: {} +unstable_nub: + # Moon 2.5.5 omits Nub from default locator inheritance. + plugin: https://github.com/moonrepo/plugins/releases/download/node_depman_toolchain-v1.1.0/node_depman_toolchain.wasm diff --git a/.moon/workspace.yml b/.moon/workspace.yml index 132e9ee..ec9db6a 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -1,5 +1,6 @@ defaultProject: solid-static projects: + compose: compose solid-static: . versionConstraint: ">=2.5.4 <3.0.0" vcs: diff --git a/AGENTS.md b/AGENTS.md index dfc3470..1774507 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ ## Tools - use `nub` as package manager -- use `nubx` to run binaries +- use `nub exec` to run binaries ## Verification diff --git a/README.md b/README.md index eb71d84..ff374ca 100644 --- a/README.md +++ b/README.md @@ -244,11 +244,10 @@ When `src` contains image metadata, specifying only `width` or `height` infers t ## Development and CI -Install Node.js 24, Nub 0.9, and Moon 2.5.5, then run: +Install Node.js 24, Nub 0.9, Moon 2.5.5, and Docker with Compose, then run: ```sh nub install --frozen-lockfile -nubx playwright install chromium moon run solid-static:check ``` @@ -256,6 +255,25 @@ moon run solid-static:check on the package build because Vite fixtures resolve the package's exported runtime from `dist`. A clean checkout does not need prebuilt artifacts. +Browser tests also depend on `compose:up-d`. The service in +`compose/docker-compose.yaml` owns one Playwright server, its readiness check, +and its lifetime. Its image installs Playwright and Chromium using the exact +client version in `package.json`; no host browser or `node_modules` mount is +needed. Compose uses host networking so the browser can reach test servers at +their normal localhost URLs, without a proxy. This works on Linux and OrbStack; +Docker Desktop 4.34 or newer needs host networking enabled in its settings. +`compose/playwright.ts` reads the server's dynamically assigned loopback endpoint +from the container. All workers share one Chromium process, with isolated contexts +and tabs per test. Disconnecting closes only that connection's contexts; the +browser and other workers keep running. A browser crash exits the service so +Compose can restart it. A connection failure fails the test. + +The JavaScript and Node toolchains in `.moon/toolchains.yml` use Nub for package +execution. Installation remains an explicit setup step, and tasks live only in +`moon.yml`. Moon does not install a second package manager or manage a second +Node version. The Nub toolchain names its official plugin explicitly because +Moon 2.5.5 omits its default plugin location. + The standalone GitHub workflow follows [Moon's CI guide](https://moonrepo.dev/docs/guides/ci): full Git history, dependency installation, then `moon ci` to select affected tasks and run their dependencies. It uploads native reports and keeps @@ -264,8 +282,14 @@ Moon workspace cache. The same project tasks can be registered as `solid-static` in a parent Moon workspace. Consumers should depend on `solid-static:build` and use a workspace -package dependency. The parent owns dependency and browser installation; the -submodule's standalone workflow does not run inside the parent's workflow. +package dependency. The parent owns dependency installation and provides +`compose:up-d` by including this repository's `compose/docker-compose.yaml` in +its own Compose project. Keep parent Playwright client versions aligned with +this package. The submodule's standalone workflow does not run inside the +parent's workflow. +The parent can extend `.moon/toolchains.yml` from this package to share the +same toolchain configuration. The `sources` file group covers package source +and build configuration for consumers' cache inputs. To publish an explicitly approved release, use `moon run solid-static:publish`. diff --git a/compose/Dockerfile b/compose/Dockerfile new file mode 100644 index 0000000..06a29ba --- /dev/null +++ b/compose/Dockerfile @@ -0,0 +1,15 @@ +FROM ghcr.io/nubjs/nub:0.9.2 + +USER root +WORKDIR /opt/playwright +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright/browsers + +# The client manifest owns the exact server version; nothing comes from host node_modules. +COPY package.json /tmp/solid-static.json +RUN node --input-type=module -e 'import fs from "node:fs"; const source = JSON.parse(fs.readFileSync("/tmp/solid-static.json", "utf8")); fs.writeFileSync("package.json", JSON.stringify({ private: true, dependencies: { "@playwright/test": source.devDependencies["@playwright/test"] } }));' \ + && nub install \ + && nub exec playwright install --with-deps --only-shell chromium \ + && rm -rf /var/lib/apt/lists/* + +COPY compose/browser-server.ts ./browser-server.ts +ENTRYPOINT ["node", "/opt/playwright/browser-server.ts"] diff --git a/compose/Dockerfile.dockerignore b/compose/Dockerfile.dockerignore new file mode 100644 index 0000000..5eab8cc --- /dev/null +++ b/compose/Dockerfile.dockerignore @@ -0,0 +1,4 @@ +** +!package.json +!compose/ +!compose/browser-server.ts diff --git a/compose/browser-server.ts b/compose/browser-server.ts new file mode 100644 index 0000000..055c4d1 --- /dev/null +++ b/compose/browser-server.ts @@ -0,0 +1,27 @@ +import { chromium } from "@playwright/test" +import { writeFile } from "node:fs/promises" + +/** Compose owns the sole Chromium process; connections own only their contexts. */ +const server = await chromium.launchServer({ + host: "127.0.0.1", + port: 0, + wsPath: "/", + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, +}) +let stopping = false + +async function stop(): Promise { + stopping = true + await server.close() +} + +process.once("SIGINT", stop) +process.once("SIGTERM", stop) +process.once("SIGHUP", stop) +server.once("close", () => { + process.exitCode = stopping ? 0 : 1 +}) +await writeFile("/tmp/playwright-endpoint", server.wsEndpoint()) +console.log(`Playwright browser ready at ${server.wsEndpoint()}`) diff --git a/compose/docker-compose.yaml b/compose/docker-compose.yaml new file mode 100644 index 0000000..1e4b1fc --- /dev/null +++ b/compose/docker-compose.yaml @@ -0,0 +1,24 @@ +name: solid-static + +services: + playwright: + build: + context: .. + dockerfile: compose/Dockerfile + init: true + restart: on-failure + shm_size: 1gb + network_mode: host + healthcheck: + test: + - CMD + - node + - -e + - >- + fetch(require('node:fs').readFileSync('/tmp/playwright-endpoint', 'utf8').replace('ws:', 'http:'), { signal: AbortSignal.timeout(1000) }) + .then(response => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1)) + interval: 30s + timeout: 3s + retries: 3 + start_interval: 1s + start_period: 20s diff --git a/compose/moon.yml b/compose/moon.yml new file mode 100644 index 0000000..8ada939 --- /dev/null +++ b/compose/moon.yml @@ -0,0 +1,13 @@ +toolchains: + default: system +tasks: + up-d: + command: docker compose -f docker-compose.yaml up -d --wait --no-recreate --remove-orphans + inputs: + - docker-compose.yaml + - Dockerfile + - Dockerfile.dockerignore + - browser-server.ts + - /package.json + options: + cache: false diff --git a/compose/playwright.ts b/compose/playwright.ts new file mode 100644 index 0000000..650c243 --- /dev/null +++ b/compose/playwright.ts @@ -0,0 +1,32 @@ +import { execFileSync } from "node:child_process" +import { join } from "node:path" + +/** Connect workers to the sole browser in the workspace's shared Compose service. */ +export function browserConnectOptions() { + const workspaceRoot = process.env["MOON_WORKSPACE_ROOT"] + if (workspaceRoot === undefined) { + throw new Error( + "Run browser tests through Moon so compose:up-d provides Playwright", + ) + } + const address = execFileSync( + "docker", + [ + "compose", "-f", join(workspaceRoot, "compose/docker-compose.yaml"), + "exec", "-T", "playwright", "node", "-p", + "require('node:fs').readFileSync('/tmp/playwright-endpoint', 'utf8')", + ], + { encoding: "utf8", timeout: 5_000 }, + ).trim() + if (!/^ws:\/\/127\.0\.0\.1:\d+\/$/u.test(address)) { + throw new Error( + `Compose Playwright must expose one loopback endpoint; received ${JSON.stringify(address)}`, + ) + } + const endpoint = new URL(address) + endpoint.hostname = "localhost" + return { + wsEndpoint: endpoint.href, + timeout: 5_000, + } +} diff --git a/e2e/browser-service.playwright.ts b/e2e/browser-service.playwright.ts new file mode 100644 index 0000000..08b50b5 --- /dev/null +++ b/e2e/browser-service.playwright.ts @@ -0,0 +1,183 @@ +import { expect, test } from "@playwright/test" +import { execFile, spawn } from "node:child_process" +import { randomUUID } from "node:crypto" +import { once } from "node:events" +import { createServer, type ServerResponse } from "node:http" +import { join } from "node:path" +import { promisify } from "node:util" + +const docker = promisify(execFile) + +test.describe("shared browser service", () => { + test("shares one Chromium process while isolating connections and contexts", async ({ + playwright, + }, testInfo) => { + const options = testInfo.project.use.connectOptions + expect(options, "All browser tests must connect to Compose").toBeDefined() + if (options === undefined) throw new Error("Missing shared browser endpoint") + + const marker = randomUUID() + await using server = createServer((_request, response) => { + response.setHeader("Content-Type", "text/html") + response.end(`

${marker}

`) + }) + server.listen(0, "127.0.0.1") + await once(server, "listening") + const address = server.address() + if (address === null || typeof address === "string") { + throw new Error("Missing loopback test server address") + } + const origin = `http://localhost:${address.port}` + const first = await playwright.chromium.connect(options) + const second = await playwright.chromium.connect(options) + try { + const firstSession = await first.newBrowserCDPSession() + const secondSession = await second.newBrowserCDPSession() + const firstProcesses = await firstSession.send("SystemInfo.getProcessInfo") + const secondProcesses = await secondSession.send("SystemInfo.getProcessInfo") + const browserProcess = firstProcesses.processInfo.find(process => process.type === "browser") + expect(browserProcess).toBeDefined() + expect(secondProcesses.processInfo.find(process => process.type === "browser")?.id).toEqual(browserProcess?.id) + await firstSession.detach() + await secondSession.detach() + const firstContext = await first.newContext() + await firstContext.addCookies([{ name: "owner", value: marker, url: origin }]) + const firstPage = await firstContext.newPage() + await firstPage.goto(origin) + await expect(firstPage.getByRole("heading")).toHaveText(marker) + + const siblingContext = await first.newContext() + expect(await siblingContext.cookies(origin)).toEqual([]) + const secondContext = await second.newContext() + expect(await secondContext.cookies(origin)).toEqual([]) + const secondPage = await secondContext.newPage() + await secondPage.goto(origin) + await expect(secondPage.getByRole("heading")).toHaveText(marker) + + await first.close() + expect(first.isConnected()).toEqual(false) + expect(firstPage.isClosed()).toEqual(true) + expect(second.isConnected()).toEqual(true) + await secondPage.reload() + await expect(secondPage.getByRole("heading")).toHaveText(marker) + + const next = await playwright.chromium.connect(options) + try { + expect(next.contexts()).toEqual([]) + const nextContext = await next.newContext() + expect(await nextContext.cookies(origin)).toEqual([]) + const nextPage = await nextContext.newPage() + await nextPage.goto(origin) + await expect(nextPage.getByRole("heading")).toHaveText(marker) + } finally { + await next.close() + } + } finally { + await first.close() + await second.close() + } + }) + + test("cleans up an interrupted client without closing other clients' tabs", async ({ + browser, + page, + }, testInfo) => { + const options = testInfo.project.use.connectOptions + if (options === undefined) throw new Error("Missing shared browser endpoint") + const observer = await browser.newBrowserCDPSession() + await observer.send("Target.setDiscoverTargets", { discover: true }) + await page.setContent("

Survivor

") + const child = spawn(process.execPath, [ + "--input-type=module", "-e", + `const { chromium } = await import(process.argv[1]); + const browser = await chromium.connect(process.argv[2]); + const context = await browser.newContext(); + const page = await context.newPage(); + await page.setContent('

Interrupted client

'); + const session = await context.newCDPSession(page); + const { targetInfo } = await session.send('Target.getTargetInfo'); + process.send(targetInfo.targetId);`, + import.meta.resolve("@playwright/test"), options.wsEndpoint, + ], { stdio: ["ignore", "ignore", "inherit", "ipc"] }) + try { + const [targetId]: unknown[] = await once(child, "message", { + signal: AbortSignal.timeout(5_000), + }) + expect(typeof targetId).toEqual("string") + const destroyed = Promise.withResolvers() + observer.on("Target.targetDestroyed", event => { + if (event.targetId === targetId) destroyed.resolve() + }) + const exited = once(child, "exit") + expect(child.kill("SIGKILL")).toEqual(true) + await exited + await destroyed.promise + expect(browser.isConnected()).toEqual(true) + await expect(page.getByRole("heading")).toHaveText("Survivor") + expect(await page.evaluate(() => 2 + 2)).toEqual(4) + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, "exit") + child.kill("SIGKILL") + await exited + } + await observer.detach() + } + }) + + test("keeps an in-flight script alive while Docker fixtures change networks", async ({ + page, + }) => { + const workspaceRoot = process.env["MOON_WORKSPACE_ROOT"] + if (workspaceRoot === undefined) throw new Error("Missing Moon workspace root") + const { stdout: image } = await docker("docker", [ + "compose", "-f", join(workspaceRoot, "compose/docker-compose.yaml"), + "images", "-q", "playwright", + ], { timeout: 5_000 }) + expect(image.trim()).not.toEqual("") + const script = Promise.withResolvers() + const marker = randomUUID() + const network = `solid-static-browser-${marker}` + const container = `${network}-fixture` + await using server = createServer((request, response) => { + if (request.url === "/held.js") { + response.setHeader("Content-Type", "text/javascript") + response.flushHeaders() + script.resolve(response) + } else { + response.setHeader("Content-Type", "text/html") + response.end('

Waiting

') + } + }) + server.listen(0, "127.0.0.1") + await once(server, "listening") + const address = server.address() + if (address === null || typeof address === "string") { + throw new Error("Missing loopback test server address") + } + const failures: string[] = [] + page.on("requestfailed", request => { + failures.push(`${request.url()}: ${request.failure()?.errorText}`) + }) + await page.goto(`http://localhost:${address.port}`, { waitUntil: "commit" }) + const held = await script.promise + try { + await docker("docker", ["network", "create", network], { timeout: 5_000 }) + await docker("docker", [ + "run", "-d", "--name", container, "--network", network, + "--entrypoint", "node", image.trim(), + "-e", "require('node:net').createServer().listen(0, '127.0.0.1')", + ], { timeout: 5_000 }) + await docker("docker", ["rm", "-f", container], { timeout: 5_000 }) + await docker("docker", ["network", "rm", network], { timeout: 5_000 }) + held.end(`document.querySelector('h1').textContent = '${marker}'`) + await expect(page.getByRole("heading")).toHaveText(marker) + expect(failures).toEqual([]) + } finally { + held.end() + await page.close() + await docker("docker", ["rm", "-f", container]).catch(() => {}) + await docker("docker", ["network", "rm", network]).catch(() => {}) + } + }) +}) diff --git a/moon.yml b/moon.yml index 14f6730..b5a0061 100644 --- a/moon.yml +++ b/moon.yml @@ -1,17 +1,30 @@ +language: typescript +layer: library +stack: frontend +tags: [node] toolchains: - default: system + default: node +fileGroups: + browser: + - compose/**/* + - package.json + sources: + - src/**/* + - package.json + - tsconfig*.json tasks: lint: - command: nubx oxlint --config .oxlintrc.json src e2e playwright.config.ts + command: nub exec oxlint --config .oxlintrc.json src e2e compose playwright.config.ts inputs: - .oxlintrc.json + - compose/**/*.ts - src/**/* - e2e/**/* - playwright.config.ts - package.json - /nub.lock build: - command: nubx tsc -p tsconfig.build.json + command: nub exec tsc -p tsconfig.build.json inputs: - src/**/* - package.json @@ -21,15 +34,16 @@ tasks: outputs: - dist/**/* typecheck: - command: nubx tsc + command: nub exec tsc inputs: - src/**/* - package.json - tsconfig.json - /nub.lock typecheck-playwright: - command: nubx tsc -p tsconfig.playwright.json + command: nub exec tsc -p tsconfig.playwright.json inputs: + - compose/**/*.ts - src/**/* - e2e/**/* - package.json @@ -38,7 +52,7 @@ tasks: - tsconfig.playwright.json - /nub.lock test-unit: - command: nubx vitest run + command: nub exec vitest run deps: - ~:build inputs: @@ -48,10 +62,12 @@ tasks: - vitest.config.ts - /nub.lock test-e2e: - command: nubx playwright test + command: nub exec playwright test deps: - ~:build + - compose:up-d inputs: + - "@group(browser)" - src/**/* - e2e/**/* - playwright.config.ts diff --git a/nub.lock b/nub.lock index 031b7c2..6518318 100644 --- a/nub.lock +++ b/nub.lock @@ -61,8 +61,8 @@ importers: version: 2.11.14(solid-js@1.9.15)(vite@8.2.1(@types/node@26.2.0)(yaml@2.9.0)) devDependencies: '@playwright/test': - specifier: ^1.62.1 - version: 1.62.1 + specifier: 1.63.0 + version: 1.63.0 '@types/hast': specifier: ^3.0.5 version: 3.0.5 @@ -501,8 +501,8 @@ packages: cpu: [x64] os: [win32] - '@playwright/test@1.62.1': - resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} engines: {node: '>=20'} hasBin: true @@ -1131,11 +1131,6 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1462,13 +1457,33 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - playwright-core@1.62.1: - resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} engines: {node: '>=20'} hasBin: true + peerDependencies: + bufferutil: '*' + chromium-bidi: '*' + electron: '*' + kerberos: '*' + utf-8-validate: '*' + zod: '*' + peerDependenciesMeta: + bufferutil: + optional: true + chromium-bidi: + optional: true + electron: + optional: true + kerberos: + optional: true + utf-8-validate: + optional: true + zod: + optional: true - playwright@1.62.1: - resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} engines: {node: '>=20'} hasBin: true @@ -2138,9 +2153,16 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.79.0': optional: true - '@playwright/test@1.62.1': + '@playwright/test@1.63.0': dependencies: - playwright: 1.62.1 + playwright: 1.63.0 + transitivePeerDependencies: + - bufferutil + - chromium-bidi + - electron + - kerberos + - utf-8-validate + - zod '@rolldown/binding-android-arm64@1.2.4': optional: true @@ -2586,9 +2608,6 @@ snapshots: format@0.2.2: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -3128,13 +3147,18 @@ snapshots: picomatch@4.0.5: {} - playwright-core@1.62.1: {} + playwright-core@1.63.0: {} - playwright@1.62.1: + playwright@1.63.0: dependencies: - playwright-core: 1.62.1 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.63.0 + transitivePeerDependencies: + - bufferutil + - chromium-bidi + - electron + - kerberos + - utf-8-validate + - zod postcss@8.5.26: dependencies: diff --git a/package.json b/package.json index 0c99ac8..1dea13b 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "vite-plugin-solid": "^2.11.14" }, "devDependencies": { - "@playwright/test": "^1.62.1", + "@playwright/test": "1.63.0", "@types/hast": "^3.0.5", "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", @@ -74,7 +74,7 @@ "devEngines": { "packageManager": { "name": "nub", - "version": "^0.4.5", + "version": "^0.9.0", "onFail": "warn" } } diff --git a/playwright.config.ts b/playwright.config.ts index 90ad90a..3ca90cd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from "@playwright/test" +import { browserConnectOptions } from "./compose/playwright.ts" export default defineConfig({ fullyParallel: false, @@ -12,5 +13,6 @@ export default defineConfig({ testDir: "./e2e", testMatch: "**/*.playwright.ts", timeout: 30_000, + use: { connectOptions: browserConnectOptions() }, workers: 1, }) diff --git a/tsconfig.playwright.json b/tsconfig.playwright.json index 4df04f4..c6089e9 100644 --- a/tsconfig.playwright.json +++ b/tsconfig.playwright.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["node", "@playwright/test"] }, - "include": ["e2e/**/*.ts", "playwright.config.ts"] + "include": ["compose/**/*.ts", "e2e/**/*.ts", "playwright.config.ts"] } From 5a2618a37f502f492f9ab182b0b95d16ae3dd732 Mon Sep 17 00:00:00 2001 From: adelnizamutdinov Date: Mon, 28 Sep 2026 02:19:25 +0300 Subject: [PATCH 2/3] Use a slim browser runtime and recommended host IPC --- README.md | 6 +++++- compose/Dockerfile | 13 +++++++++++-- compose/docker-compose.yaml | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ff374ca..fabb259 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,11 @@ Browser tests also depend on `compose:up-d`. The service in `compose/docker-compose.yaml` owns one Playwright server, its readiness check, and its lifetime. Its image installs Playwright and Chromium using the exact client version in `package.json`; no host browser or `node_modules` mount is -needed. Compose uses host networking so the browser can reach test servers at +needed. The runtime uses Debian slim and only Chromium's headless shell. Nub +installs dependencies in a separate build stage, keeping its binaries and cache +out of the runtime image. Compose enables `init` and host IPC following +[Playwright's Docker recommendations](https://playwright.dev/docs/docker#recommended-docker-configuration). +Compose uses host networking so the browser can reach test servers at their normal localhost URLs, without a proxy. This works on Linux and OrbStack; Docker Desktop 4.34 or newer needs host networking enabled in its settings. `compose/playwright.ts` reads the server's dynamically assigned loopback endpoint diff --git a/compose/Dockerfile b/compose/Dockerfile index 06a29ba..3abdfcd 100644 --- a/compose/Dockerfile +++ b/compose/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/nubjs/nub:0.9.2 +FROM ghcr.io/nubjs/nub:0.9.2-slim AS install USER root WORKDIR /opt/playwright @@ -8,7 +8,16 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright/browsers COPY package.json /tmp/solid-static.json RUN node --input-type=module -e 'import fs from "node:fs"; const source = JSON.parse(fs.readFileSync("/tmp/solid-static.json", "utf8")); fs.writeFileSync("package.json", JSON.stringify({ private: true, dependencies: { "@playwright/test": source.devDependencies["@playwright/test"] } }));' \ && nub install \ - && nub exec playwright install --with-deps --only-shell chromium \ + && nub ci \ + && nub exec playwright install --only-shell chromium + +# Keep package-manager binaries and caches out of the browser runtime. +# Debian slim supports Playwright's glibc builds; Alpine does not. +FROM node:26.3.1-trixie-slim +WORKDIR /opt/playwright +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright/browsers +COPY --from=install /opt/playwright /opt/playwright +RUN node node_modules/@playwright/test/cli.js install-deps chromium \ && rm -rf /var/lib/apt/lists/* COPY compose/browser-server.ts ./browser-server.ts diff --git a/compose/docker-compose.yaml b/compose/docker-compose.yaml index 1e4b1fc..7c69ddc 100644 --- a/compose/docker-compose.yaml +++ b/compose/docker-compose.yaml @@ -7,7 +7,7 @@ services: dockerfile: compose/Dockerfile init: true restart: on-failure - shm_size: 1gb + ipc: host network_mode: host healthcheck: test: From bd1c77bd4357305c42a97e34af41bafb8c4b0d9e Mon Sep 17 00:00:00 2001 From: adelnizamutdinov Date: Mon, 28 Sep 2026 02:36:37 +0300 Subject: [PATCH 3/3] Test generated site artifacts in Node without Playwright or Compose --- .github/workflows/ci.yaml | 7 - .gitignore | 2 - .moon/workspace.yml | 1 - README.md | 33 +- compose/Dockerfile | 24 -- compose/Dockerfile.dockerignore | 4 - compose/browser-server.ts | 27 -- compose/docker-compose.yaml | 24 -- compose/moon.yml | 13 - compose/playwright.ts | 32 -- e2e/browser-service.playwright.ts | 183 ---------- e2e/client-islands.playwright.ts | 478 ------------------------- moon.yml | 38 +- nub.lock | 211 +++++++---- package.json | 4 +- playwright.config.ts | 18 - src/client-islands-integration.test.ts | 382 ++++++++++++++++++++ tsconfig.playwright.json | 7 - 18 files changed, 547 insertions(+), 941 deletions(-) delete mode 100644 compose/Dockerfile delete mode 100644 compose/Dockerfile.dockerignore delete mode 100644 compose/browser-server.ts delete mode 100644 compose/docker-compose.yaml delete mode 100644 compose/moon.yml delete mode 100644 compose/playwright.ts delete mode 100644 e2e/browser-service.playwright.ts delete mode 100644 e2e/client-islands.playwright.ts delete mode 100644 playwright.config.ts create mode 100644 src/client-islands-integration.test.ts delete mode 100644 tsconfig.playwright.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1db844c..e9fffc0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -52,10 +52,3 @@ jobs: include-hidden-files: true if-no-files-found: ignore retention-days: 14 - - uses: actions/upload-artifact@v7 - if: always() - with: - name: test-results-${{ github.run_attempt }} - path: test-results/ - if-no-files-found: ignore - retention-days: 1 diff --git a/.gitignore b/.gitignore index 918bb69..2c794d4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,3 @@ node_modules/ dist/ .npmrc .solid-static-islands* -playwright-report/ -test-results/ diff --git a/.moon/workspace.yml b/.moon/workspace.yml index ec9db6a..132e9ee 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -1,6 +1,5 @@ defaultProject: solid-static projects: - compose: compose solid-static: . versionConstraint: ">=2.5.4 <3.0.0" vcs: diff --git a/README.md b/README.md index fabb259..3f0690e 100644 --- a/README.md +++ b/README.md @@ -244,33 +244,23 @@ When `src` contains image metadata, specifying only `width` or `height` infers t ## Development and CI -Install Node.js 24, Nub 0.9, Moon 2.5.5, and Docker with Compose, then run: +Install Node.js 24, Nub 0.9, and Moon 2.5.5, then run: ```sh nub install --frozen-lockfile moon run solid-static:check ``` -`moon.yml` owns the build, lint, typecheck, unit, and browser tasks. Tests depend +`moon.yml` owns the build, lint, typecheck, and Vitest tasks. Tests depend on the package build because Vite fixtures resolve the package's exported runtime from `dist`. A clean checkout does not need prebuilt artifacts. -Browser tests also depend on `compose:up-d`. The service in -`compose/docker-compose.yaml` owns one Playwright server, its readiness check, -and its lifetime. Its image installs Playwright and Chromium using the exact -client version in `package.json`; no host browser or `node_modules` mount is -needed. The runtime uses Debian slim and only Chromium's headless shell. Nub -installs dependencies in a separate build stage, keeping its binaries and cache -out of the runtime image. Compose enables `init` and host IPC following -[Playwright's Docker recommendations](https://playwright.dev/docs/docker#recommended-docker-configuration). -Compose uses host networking so the browser can reach test servers at -their normal localhost URLs, without a proxy. This works on Linux and OrbStack; -Docker Desktop 4.34 or newer needs host networking enabled in its settings. -`compose/playwright.ts` reads the server's dynamically assigned loopback endpoint -from the container. All workers share one Chromium process, with isolated contexts -and tabs per test. Disconnecting closes only that connection's contexts; the -browser and other workers keep running. A browser crash exits the service so -Compose can restart it. A connection failure fails the test. +All tests run in Node without a browser or Docker. Integrated Vite fixtures build +real sites, then Cheerio, PostCSS, and Acorn check generated HTML, CSS, and +JavaScript: static fallback content, hashed links, stylesheet deduplication, +page-specific styles, shared chunks, and root, relative, subpath, and CDN bases. +Development tests request Vite's HTML and transformed assets directly over HTTP. +Each fixture owns its temporary files and ephemeral ports. The JavaScript and Node toolchains in `.moon/toolchains.yml` use Nub for package execution. Installation remains an explicit setup step, and tasks live only in @@ -286,11 +276,8 @@ Moon workspace cache. The same project tasks can be registered as `solid-static` in a parent Moon workspace. Consumers should depend on `solid-static:build` and use a workspace -package dependency. The parent owns dependency installation and provides -`compose:up-d` by including this repository's `compose/docker-compose.yaml` in -its own Compose project. Keep parent Playwright client versions aligned with -this package. The submodule's standalone workflow does not run inside the -parent's workflow. +package dependency. The parent owns dependency installation. The submodule's +standalone workflow does not run inside the parent's workflow. The parent can extend `.moon/toolchains.yml` from this package to share the same toolchain configuration. The `sources` file group covers package source and build configuration for consumers' cache inputs. diff --git a/compose/Dockerfile b/compose/Dockerfile deleted file mode 100644 index 3abdfcd..0000000 --- a/compose/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -FROM ghcr.io/nubjs/nub:0.9.2-slim AS install - -USER root -WORKDIR /opt/playwright -ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright/browsers - -# The client manifest owns the exact server version; nothing comes from host node_modules. -COPY package.json /tmp/solid-static.json -RUN node --input-type=module -e 'import fs from "node:fs"; const source = JSON.parse(fs.readFileSync("/tmp/solid-static.json", "utf8")); fs.writeFileSync("package.json", JSON.stringify({ private: true, dependencies: { "@playwright/test": source.devDependencies["@playwright/test"] } }));' \ - && nub install \ - && nub ci \ - && nub exec playwright install --only-shell chromium - -# Keep package-manager binaries and caches out of the browser runtime. -# Debian slim supports Playwright's glibc builds; Alpine does not. -FROM node:26.3.1-trixie-slim -WORKDIR /opt/playwright -ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright/browsers -COPY --from=install /opt/playwright /opt/playwright -RUN node node_modules/@playwright/test/cli.js install-deps chromium \ - && rm -rf /var/lib/apt/lists/* - -COPY compose/browser-server.ts ./browser-server.ts -ENTRYPOINT ["node", "/opt/playwright/browser-server.ts"] diff --git a/compose/Dockerfile.dockerignore b/compose/Dockerfile.dockerignore deleted file mode 100644 index 5eab8cc..0000000 --- a/compose/Dockerfile.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -** -!package.json -!compose/ -!compose/browser-server.ts diff --git a/compose/browser-server.ts b/compose/browser-server.ts deleted file mode 100644 index 055c4d1..0000000 --- a/compose/browser-server.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { chromium } from "@playwright/test" -import { writeFile } from "node:fs/promises" - -/** Compose owns the sole Chromium process; connections own only their contexts. */ -const server = await chromium.launchServer({ - host: "127.0.0.1", - port: 0, - wsPath: "/", - handleSIGINT: false, - handleSIGTERM: false, - handleSIGHUP: false, -}) -let stopping = false - -async function stop(): Promise { - stopping = true - await server.close() -} - -process.once("SIGINT", stop) -process.once("SIGTERM", stop) -process.once("SIGHUP", stop) -server.once("close", () => { - process.exitCode = stopping ? 0 : 1 -}) -await writeFile("/tmp/playwright-endpoint", server.wsEndpoint()) -console.log(`Playwright browser ready at ${server.wsEndpoint()}`) diff --git a/compose/docker-compose.yaml b/compose/docker-compose.yaml deleted file mode 100644 index 7c69ddc..0000000 --- a/compose/docker-compose.yaml +++ /dev/null @@ -1,24 +0,0 @@ -name: solid-static - -services: - playwright: - build: - context: .. - dockerfile: compose/Dockerfile - init: true - restart: on-failure - ipc: host - network_mode: host - healthcheck: - test: - - CMD - - node - - -e - - >- - fetch(require('node:fs').readFileSync('/tmp/playwright-endpoint', 'utf8').replace('ws:', 'http:'), { signal: AbortSignal.timeout(1000) }) - .then(response => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1)) - interval: 30s - timeout: 3s - retries: 3 - start_interval: 1s - start_period: 20s diff --git a/compose/moon.yml b/compose/moon.yml deleted file mode 100644 index 8ada939..0000000 --- a/compose/moon.yml +++ /dev/null @@ -1,13 +0,0 @@ -toolchains: - default: system -tasks: - up-d: - command: docker compose -f docker-compose.yaml up -d --wait --no-recreate --remove-orphans - inputs: - - docker-compose.yaml - - Dockerfile - - Dockerfile.dockerignore - - browser-server.ts - - /package.json - options: - cache: false diff --git a/compose/playwright.ts b/compose/playwright.ts deleted file mode 100644 index 650c243..0000000 --- a/compose/playwright.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { execFileSync } from "node:child_process" -import { join } from "node:path" - -/** Connect workers to the sole browser in the workspace's shared Compose service. */ -export function browserConnectOptions() { - const workspaceRoot = process.env["MOON_WORKSPACE_ROOT"] - if (workspaceRoot === undefined) { - throw new Error( - "Run browser tests through Moon so compose:up-d provides Playwright", - ) - } - const address = execFileSync( - "docker", - [ - "compose", "-f", join(workspaceRoot, "compose/docker-compose.yaml"), - "exec", "-T", "playwright", "node", "-p", - "require('node:fs').readFileSync('/tmp/playwright-endpoint', 'utf8')", - ], - { encoding: "utf8", timeout: 5_000 }, - ).trim() - if (!/^ws:\/\/127\.0\.0\.1:\d+\/$/u.test(address)) { - throw new Error( - `Compose Playwright must expose one loopback endpoint; received ${JSON.stringify(address)}`, - ) - } - const endpoint = new URL(address) - endpoint.hostname = "localhost" - return { - wsEndpoint: endpoint.href, - timeout: 5_000, - } -} diff --git a/e2e/browser-service.playwright.ts b/e2e/browser-service.playwright.ts deleted file mode 100644 index 08b50b5..0000000 --- a/e2e/browser-service.playwright.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { expect, test } from "@playwright/test" -import { execFile, spawn } from "node:child_process" -import { randomUUID } from "node:crypto" -import { once } from "node:events" -import { createServer, type ServerResponse } from "node:http" -import { join } from "node:path" -import { promisify } from "node:util" - -const docker = promisify(execFile) - -test.describe("shared browser service", () => { - test("shares one Chromium process while isolating connections and contexts", async ({ - playwright, - }, testInfo) => { - const options = testInfo.project.use.connectOptions - expect(options, "All browser tests must connect to Compose").toBeDefined() - if (options === undefined) throw new Error("Missing shared browser endpoint") - - const marker = randomUUID() - await using server = createServer((_request, response) => { - response.setHeader("Content-Type", "text/html") - response.end(`

${marker}

`) - }) - server.listen(0, "127.0.0.1") - await once(server, "listening") - const address = server.address() - if (address === null || typeof address === "string") { - throw new Error("Missing loopback test server address") - } - const origin = `http://localhost:${address.port}` - const first = await playwright.chromium.connect(options) - const second = await playwright.chromium.connect(options) - try { - const firstSession = await first.newBrowserCDPSession() - const secondSession = await second.newBrowserCDPSession() - const firstProcesses = await firstSession.send("SystemInfo.getProcessInfo") - const secondProcesses = await secondSession.send("SystemInfo.getProcessInfo") - const browserProcess = firstProcesses.processInfo.find(process => process.type === "browser") - expect(browserProcess).toBeDefined() - expect(secondProcesses.processInfo.find(process => process.type === "browser")?.id).toEqual(browserProcess?.id) - await firstSession.detach() - await secondSession.detach() - const firstContext = await first.newContext() - await firstContext.addCookies([{ name: "owner", value: marker, url: origin }]) - const firstPage = await firstContext.newPage() - await firstPage.goto(origin) - await expect(firstPage.getByRole("heading")).toHaveText(marker) - - const siblingContext = await first.newContext() - expect(await siblingContext.cookies(origin)).toEqual([]) - const secondContext = await second.newContext() - expect(await secondContext.cookies(origin)).toEqual([]) - const secondPage = await secondContext.newPage() - await secondPage.goto(origin) - await expect(secondPage.getByRole("heading")).toHaveText(marker) - - await first.close() - expect(first.isConnected()).toEqual(false) - expect(firstPage.isClosed()).toEqual(true) - expect(second.isConnected()).toEqual(true) - await secondPage.reload() - await expect(secondPage.getByRole("heading")).toHaveText(marker) - - const next = await playwright.chromium.connect(options) - try { - expect(next.contexts()).toEqual([]) - const nextContext = await next.newContext() - expect(await nextContext.cookies(origin)).toEqual([]) - const nextPage = await nextContext.newPage() - await nextPage.goto(origin) - await expect(nextPage.getByRole("heading")).toHaveText(marker) - } finally { - await next.close() - } - } finally { - await first.close() - await second.close() - } - }) - - test("cleans up an interrupted client without closing other clients' tabs", async ({ - browser, - page, - }, testInfo) => { - const options = testInfo.project.use.connectOptions - if (options === undefined) throw new Error("Missing shared browser endpoint") - const observer = await browser.newBrowserCDPSession() - await observer.send("Target.setDiscoverTargets", { discover: true }) - await page.setContent("

Survivor

") - const child = spawn(process.execPath, [ - "--input-type=module", "-e", - `const { chromium } = await import(process.argv[1]); - const browser = await chromium.connect(process.argv[2]); - const context = await browser.newContext(); - const page = await context.newPage(); - await page.setContent('

Interrupted client

'); - const session = await context.newCDPSession(page); - const { targetInfo } = await session.send('Target.getTargetInfo'); - process.send(targetInfo.targetId);`, - import.meta.resolve("@playwright/test"), options.wsEndpoint, - ], { stdio: ["ignore", "ignore", "inherit", "ipc"] }) - try { - const [targetId]: unknown[] = await once(child, "message", { - signal: AbortSignal.timeout(5_000), - }) - expect(typeof targetId).toEqual("string") - const destroyed = Promise.withResolvers() - observer.on("Target.targetDestroyed", event => { - if (event.targetId === targetId) destroyed.resolve() - }) - const exited = once(child, "exit") - expect(child.kill("SIGKILL")).toEqual(true) - await exited - await destroyed.promise - expect(browser.isConnected()).toEqual(true) - await expect(page.getByRole("heading")).toHaveText("Survivor") - expect(await page.evaluate(() => 2 + 2)).toEqual(4) - } finally { - if (child.exitCode === null && child.signalCode === null) { - const exited = once(child, "exit") - child.kill("SIGKILL") - await exited - } - await observer.detach() - } - }) - - test("keeps an in-flight script alive while Docker fixtures change networks", async ({ - page, - }) => { - const workspaceRoot = process.env["MOON_WORKSPACE_ROOT"] - if (workspaceRoot === undefined) throw new Error("Missing Moon workspace root") - const { stdout: image } = await docker("docker", [ - "compose", "-f", join(workspaceRoot, "compose/docker-compose.yaml"), - "images", "-q", "playwright", - ], { timeout: 5_000 }) - expect(image.trim()).not.toEqual("") - const script = Promise.withResolvers() - const marker = randomUUID() - const network = `solid-static-browser-${marker}` - const container = `${network}-fixture` - await using server = createServer((request, response) => { - if (request.url === "/held.js") { - response.setHeader("Content-Type", "text/javascript") - response.flushHeaders() - script.resolve(response) - } else { - response.setHeader("Content-Type", "text/html") - response.end('

Waiting

') - } - }) - server.listen(0, "127.0.0.1") - await once(server, "listening") - const address = server.address() - if (address === null || typeof address === "string") { - throw new Error("Missing loopback test server address") - } - const failures: string[] = [] - page.on("requestfailed", request => { - failures.push(`${request.url()}: ${request.failure()?.errorText}`) - }) - await page.goto(`http://localhost:${address.port}`, { waitUntil: "commit" }) - const held = await script.promise - try { - await docker("docker", ["network", "create", network], { timeout: 5_000 }) - await docker("docker", [ - "run", "-d", "--name", container, "--network", network, - "--entrypoint", "node", image.trim(), - "-e", "require('node:net').createServer().listen(0, '127.0.0.1')", - ], { timeout: 5_000 }) - await docker("docker", ["rm", "-f", container], { timeout: 5_000 }) - await docker("docker", ["network", "rm", network], { timeout: 5_000 }) - held.end(`document.querySelector('h1').textContent = '${marker}'`) - await expect(page.getByRole("heading")).toHaveText(marker) - expect(failures).toEqual([]) - } finally { - held.end() - await page.close() - await docker("docker", ["rm", "-f", container]).catch(() => {}) - await docker("docker", ["network", "rm", network]).catch(() => {}) - } - }) -}) diff --git a/e2e/client-islands.playwright.ts b/e2e/client-islands.playwright.ts deleted file mode 100644 index 357a653..0000000 --- a/e2e/client-islands.playwright.ts +++ /dev/null @@ -1,478 +0,0 @@ -import { expect, test } from "@playwright/test" -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises" -import { - createServer as createHttpServer, - type Server as HttpServer, -} from "node:http" -import { extname, join } from "node:path" -import { - build, - createServer as createViteServer, - type Plugin, - type UserConfig, -} from "vite" -import { staticSite } from "../src/index.ts" - -interface ListeningServer { - close: () => Promise - url: string -} - -const staticSitePlugins = (client?: UserConfig) => - staticSite({ - ...(client === undefined ? {} : { client }), - collections: {}, - i18n: { - defaultLocale: "en", - locales: ["en"], - routing: { prefixDefaultLocale: false }, - }, - integrations: [], - markdown: { - processor: { - async process() { - return { toString: () => "" } - }, - }, - }, - trailingSlash: "always", - }) - -const clientBaseCases = [ - { base: "/", name: "root-absolute" }, - { base: "/docs/", name: "subpath" }, - { base: "./", name: "relative" }, - { base: "https://cdn.example.com/static/", name: "CDN" }, -] - -function clientTransform(): Plugin { - return { - name: "client-fixture-transform", - transform(code, id) { - return id.endsWith("/shared.ts") - ? code.replace("__CLIENT_TRANSFORM__", "transformed") - : undefined - }, - } -} - -const listen = async (server: HttpServer): Promise => { - await new Promise((resolve, reject) => { - server.once("error", reject) - server.listen(0, "127.0.0.1", () => { - server.off("error", reject) - resolve() - }) - }) - const address = server.address() - - if (address === null || typeof address === "string") { - throw new TypeError("Expected test server TCP address") - } - - async function close(): Promise { - if (!server.listening) { - return - } - - await new Promise((resolve, reject) => { - server.close(error => { - if (error === undefined) { - resolve() - } else { - reject(error) - } - }) - server.closeAllConnections() - }) - } - - return { close, url: `http://127.0.0.1:${address.port}` } -} - -const createFixture = async (): Promise => { - const root = await mkdtemp(join(process.cwd(), ".solid-static-islands-")) - const sourceDirectory = join(root, "src") - - await mkdir(join(sourceDirectory, "pages"), { recursive: true }) - await Promise.all([ - writeFile( - join(sourceDirectory, "pages", "index.tsx"), - `import islandUrl from "../counter-island.tsx?island" -import stylesheetUrl from "../page.css?url" - -export default () => ( - - - Island fixture - - - -

Static fallback

-
Loading client island
- - - -) -`, - ), - writeFile( - join(sourceDirectory, "counter-island.tsx"), - `import { createSignal } from "solid-js" -import { render } from "solid-js/web" -import "./counter.css" - -const Counter = () => { - const [count, setCount] = createSignal(0) - - return ( - - ) -} - -const root = document.querySelector("#counter") - -if (!(root instanceof HTMLElement)) { - throw new TypeError("Missing #counter island root") -} - -render(() => , root) -`, - ), - writeFile( - join(sourceDirectory, "counter.css"), - `#counter button { color: rgb(1, 2, 3); } -`, - ), - writeFile( - join(sourceDirectory, "page.css"), - `body { margin: 0; } -`, - ), - ]) - - return root -} - -const createMatrixFixture = async (): Promise => { - const root = await mkdtemp(join(process.cwd(), ".solid-static-matrix-")) - const sourceDirectory = join(root, "src") - const clientDirectory = join(sourceDirectory, "client") - - await mkdir(join(sourceDirectory, "pages"), { recursive: true }) - await mkdir(clientDirectory, { recursive: true }) - await Promise.all([ - writeFile( - join(sourceDirectory, "pages", "index.tsx"), - `import islandUrl from "../client/alpha.tsx?island" - -export default () => ( - Alpha -
Alpha fallback
- - -) -`, - ), - writeFile( - join(sourceDirectory, "pages", "beta.tsx"), - `import islandUrl from "../client/beta.tsx?island" - -export default () => ( - Beta -
Beta fallback
- - -) -`, - ), - writeFile( - join(sourceDirectory, "pages", "plain.tsx"), - `export default () => ( - PlainNo islands here -) -`, - ), - writeFile( - join(clientDirectory, "shared.ts"), - `export const definedLabel = CLIENT_LABEL -export const mode = import.meta.env.MODE -export const transformedLabel = "__CLIENT_TRANSFORM__" -export const sharedPayload = "shared dependency payload retained as one chunk" -`, - ), - writeFile( - join(clientDirectory, "alpha.tsx"), - `import { render } from "solid-js/web" -import { definedLabel, mode, sharedPayload, transformedLabel } from "@client/shared.ts" -import styles from "./alpha.module.css" - -const root = document.querySelector("#alpha") -if (!(root instanceof HTMLElement)) throw new TypeError("Missing alpha root") -render(() => , root) -`, - ), - writeFile( - join(clientDirectory, "beta.tsx"), - `import { render } from "solid-js/web" -import { definedLabel, mode, sharedPayload, transformedLabel } from "@client/shared.ts" -import styles from "./beta.module.css" - -const root = document.querySelector("#beta") -if (!(root instanceof HTMLElement)) throw new TypeError("Missing beta root") -render(() => , root) -`, - ), - writeFile( - join(clientDirectory, "alpha.module.css"), - `.alpha { color: rgb(11, 12, 13); } -`, - ), - writeFile( - join(clientDirectory, "beta.module.css"), - `.beta { color: rgb(21, 22, 23); } -`, - ), - ]) - - return root -} - -const matrixClientConfig = (root: string): UserConfig => ({ - build: { minify: false, target: "es2020" }, - css: { modules: { generateScopedName: "client_[local]" } }, - define: { CLIENT_LABEL: JSON.stringify("defined") }, - mode: "client-fixture", - plugins: [clientTransform()], - resolve: { alias: { "@client": join(root, "src", "client") } }, -}) - -const createStaticServer = async ( - directory: string, -): Promise => { - const server = createHttpServer((request, response) => { - async function respond(): Promise { - const pathname = new URL( - request.url ?? "/", - "http://solid-static.local", - ).pathname - const fileName = - pathname === "/" - ? "index.html" - : pathname.endsWith("/") - ? `${pathname.slice(1)}index.html` - : pathname.slice(1) - - try { - const body = await readFile(join(directory, fileName)) - const contentTypes: Record = { - ".css": "text/css; charset=utf-8", - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - } - const contentType = contentTypes[extname(fileName)] - - response.statusCode = 200 - if (contentType !== undefined) { - response.setHeader("Content-Type", contentType) - } - response.end(body) - } catch { - response.statusCode = 404 - response.end("Not found") - } - } - - void respond() - }) - - return listen(server) -} - -test.describe("client islands", () => { - for (const { base, name } of clientBaseCases) { - test(`maps split CSS and client config with ${name} base`, async () => { - const root = await createMatrixFixture() - const outputDirectory = join(root, "dist") - - try { - await build({ - base, - build: { minify: false, outDir: outputDirectory }, - logLevel: "silent", - plugins: [staticSitePlugins(matrixClientConfig(root))], - root, - }) - - const alphaHtml = await readFile( - join(outputDirectory, "index.html"), - "utf8", - ) - const betaHtml = await readFile( - join(outputDirectory, "beta", "index.html"), - "utf8", - ) - const plainHtml = await readFile( - join(outputDirectory, "plain", "index.html"), - "utf8", - ) - const alphaStyles = [...alphaHtml.matchAll(/href="([^"]+\.css)"/g)] - .map(match => match[1]) - const betaStyles = [...betaHtml.matchAll(/href="([^"]+\.css)"/g)] - .map(match => match[1]) - const alphaScripts = [...alphaHtml.matchAll(/src="([^"]+\.js)"/g)] - .map(match => match[1]) - const betaScripts = [...betaHtml.matchAll(/src="([^"]+\.js)"/g)] - .map(match => match[1]) - const outputFiles = await readdir(outputDirectory, { recursive: true }) - const islandFiles = outputFiles.filter(fileName => - fileName.startsWith("assets/islands/"), - ) - const cssFiles = islandFiles.filter(fileName => fileName.endsWith(".css")) - const cssSources = await Promise.all( - cssFiles.map(fileName => readFile(join(outputDirectory, fileName), "utf8")), - ) - const entryJavaScript = await Promise.all( - islandFiles - .filter( - fileName => - fileName.endsWith(".js") && !fileName.includes("/chunks/"), - ) - .map(fileName => readFile(join(outputDirectory, fileName), "utf8")), - ) - const sharedJavaScript = await readFile( - join( - outputDirectory, - islandFiles.find( - fileName => - fileName.includes("/chunks/") && fileName.endsWith(".js"), - ) ?? "missing-shared-chunk", - ), - "utf8", - ) - const expectedRootPrefix = base - const expectedNestedPrefix = base === "./" ? "../" : base - const escapedRootPrefix = expectedRootPrefix.replace( - /[.*+?^${}()|[\]\\]/g, - "\\$&", - ) - const escapedNestedPrefix = expectedNestedPrefix.replace( - /[.*+?^${}()|[\]\\]/g, - "\\$&", - ) - - expect(alphaStyles).toHaveLength(1) - expect(betaStyles).toHaveLength(1) - expect(alphaScripts).toHaveLength(1) - expect(betaScripts).toHaveLength(1) - expect(alphaStyles[0]).not.toEqual(betaStyles[0]) - expect(alphaStyles[0]).toMatch( - new RegExp(`^${escapedRootPrefix}assets/islands/`), - ) - expect(betaStyles[0]).toMatch( - new RegExp(`^${escapedNestedPrefix}assets/islands/`), - ) - expect(alphaScripts[0]).toMatch( - new RegExp(`^${escapedRootPrefix}assets/islands/`), - ) - expect(betaScripts[0]).toMatch( - new RegExp(`^${escapedNestedPrefix}assets/islands/`), - ) - expect(cssSources.some(source => source.includes(".client_alpha"))).toEqual(true) - expect(cssSources.some(source => source.includes(".client_beta"))).toEqual(true) - expect( - islandFiles.filter(fileName => fileName.includes("/chunks/") && fileName.endsWith(".js")), - ).toHaveLength(1) - expect(entryJavaScript.every(source => source.includes("\n"))).toEqual(true) - expect(sharedJavaScript).toContain('"defined"') - expect(sharedJavaScript).toContain('"transformed"') - expect(sharedJavaScript).toContain('"client-fixture"') - expect(plainHtml).toContain("No islands here") - expect(plainHtml).not.toContain("assets/islands/") - expect(outputFiles).not.toContain(".vite/solid-static-islands-manifest.json") - } finally { - await rm(root, { force: true, recursive: true }) - } - }) - } - - test("builds hashed browser JavaScript and CSS that execute over static HTML", async ({ - page, - }) => { - const root = await createFixture() - const outputDirectory = join(root, "dist") - let server: ListeningServer | undefined - - try { - await build({ - build: { outDir: outputDirectory }, - logLevel: "silent", - plugins: [staticSitePlugins()], - root, - }) - const html = await readFile(join(outputDirectory, "index.html"), "utf8") - - expect(html).toContain("Static fallback") - expect(html).not.toContain("__SOLID_STATIC_ISLAND_") - expect(html).toMatch( - / + + +) +`, + ), + writeFile( + join(sourceDirectory, "counter-island.tsx"), + `import { createSignal } from "solid-js" +import { render } from "solid-js/web" +import "./counter.css" + +const Counter = () => { + const [count, setCount] = createSignal(0) + + return ( + + ) +} + +const root = document.querySelector("#counter") + +if (!(root instanceof HTMLElement)) { + throw new TypeError("Missing #counter island root") +} + +render(() => , root) +`, + ), + writeFile( + join(sourceDirectory, "counter.css"), + `#counter button { color: rgb(1, 2, 3); } +`, + ), + writeFile( + join(sourceDirectory, "page.css"), + `body { margin: 0; } +`, + ), + ]) + + return root +} + +const createMatrixFixture = async (): Promise => { + const root = await mkdtemp(join(process.cwd(), ".solid-static-matrix-")) + const sourceDirectory = join(root, "src") + const clientDirectory = join(sourceDirectory, "client") + + await mkdir(join(sourceDirectory, "pages"), { recursive: true }) + await mkdir(clientDirectory, { recursive: true }) + await Promise.all([ + writeFile( + join(sourceDirectory, "pages", "index.tsx"), + `import islandUrl from "../client/alpha.tsx?island" + +export default () => ( + Alpha +
Alpha fallback
+ + +) +`, + ), + writeFile( + join(sourceDirectory, "pages", "beta.tsx"), + `import islandUrl from "../client/beta.tsx?island" + +export default () => ( + Beta +
Beta fallback
+ + +) +`, + ), + writeFile( + join(sourceDirectory, "pages", "plain.tsx"), + `export default () => ( + PlainNo islands here +) +`, + ), + writeFile( + join(clientDirectory, "shared.ts"), + `export const definedLabel = CLIENT_LABEL +export const mode = import.meta.env.MODE +export const transformedLabel = "__CLIENT_TRANSFORM__" +export const sharedPayload = "shared dependency payload retained as one chunk" +`, + ), + writeFile( + join(clientDirectory, "alpha.tsx"), + `import { render } from "solid-js/web" +import { definedLabel, mode, sharedPayload, transformedLabel } from "@client/shared.ts" +import styles from "./alpha.module.css" + +const root = document.querySelector("#alpha") +if (!(root instanceof HTMLElement)) throw new TypeError("Missing alpha root") +render(() => , root) +`, + ), + writeFile( + join(clientDirectory, "beta.tsx"), + `import { render } from "solid-js/web" +import { definedLabel, mode, sharedPayload, transformedLabel } from "@client/shared.ts" +import styles from "./beta.module.css" + +const root = document.querySelector("#beta") +if (!(root instanceof HTMLElement)) throw new TypeError("Missing beta root") +render(() => , root) +`, + ), + writeFile( + join(clientDirectory, "alpha.module.css"), + `.alpha { color: rgb(11, 12, 13); } +`, + ), + writeFile( + join(clientDirectory, "beta.module.css"), + `.beta { color: rgb(21, 22, 23); } +`, + ), + ]) + + return root +} + +const matrixClientConfig = (root: string): UserConfig => ({ + build: { minify: false, cssMinify: false, target: "es2020" }, + css: { modules: { generateScopedName: "client_[local]" } }, + define: { CLIENT_LABEL: JSON.stringify("defined") }, + mode: "client-fixture", + plugins: [clientTransform()], + resolve: { alias: { "@client": join(root, "src", "client") } }, +}) + + +// Resolve a generated public asset URL exactly as its containing HTML page does. +function outputAsset(url: string, route: string, base: string): string { + const root = new URL(base, "http://solid-static.test/") + const resolved = new URL(url, new URL(route, root)) + expect(resolved.origin).toEqual(root.origin) + expect(resolved.pathname.startsWith(root.pathname)).toEqual(true) + return resolved.pathname.slice(root.pathname.length) +} + +async function assertJavaScriptFiles(directory: string): Promise { + const files = await readdir(directory, { recursive: true }) + const scripts = files.filter(file => file.endsWith(".js")) + expect(scripts.length).toBeGreaterThan(0) + await Promise.all(scripts.map(async file => { + const source = await readFile(join(directory, file), "utf8") + const program = parseJavaScript(source, { ecmaVersion: "latest", sourceType: "module" }) + expect(program.body.length).toBeGreaterThan(0) + expect(source).not.toContain("__SOLID_STATIC_ISLAND_") + for (const statement of program.body) { + if (statement.type !== "ImportDeclaration") continue + const imported = statement.source.value + if (typeof imported !== "string") throw new TypeError("Expected a module URL") + expect(imported.startsWith(".")).toEqual(true) + expect(files).toContain(posix.normalize(posix.join(posix.dirname(file), imported))) + } + })) +} + +function cssRules(source: string): Record> { + const rules: Record> = {} + parseCss(source).walkRules(rule => { + const declarations: Record = {} + rule.walkDecls(declaration => { declarations[declaration.prop] = declaration.value }) + rules[rule.selector] = declarations + }) + return rules +} + +describe("client island output", () => { + test.concurrent.each(clientBaseCases)("maps emitted assets with $name base", async ({ base }) => { + expect(typeof document).toEqual("undefined") + const root = await createMatrixFixture() + const outputDirectory = join(root, "dist") + try { + await build({ + base, + build: { minify: false, outDir: outputDirectory }, + logLevel: "silent", + plugins: [staticSitePlugins(matrixClientConfig(root))], + root, + }) + const outputFiles = await readdir(outputDirectory, { recursive: true }) + const styles: string[] = [] + const entries: string[] = [] + await Promise.all([ + { route: "index.html", name: "alpha", prefix: base, color: "rgb(11, 12, 13)" }, + { route: "beta/index.html", name: "beta", prefix: base === "./" ? "../" : base, color: "rgb(21, 22, 23)" }, + ].map(async ({ route, name, prefix, color }) => { + const html = await readFile(join(outputDirectory, route), "utf8") + const $ = loadHtml(html) + expect($("title").text().toLowerCase()).toEqual(name) + expect($("#" + name).text()).toEqual(name === "alpha" ? "Alpha fallback" : "Beta fallback") + expect($("script[type=module][src]")).toHaveLength(1) + expect($("link[rel=stylesheet][href]")).toHaveLength(1) + expect(html).not.toContain("__SOLID_STATIC_ISLAND_") + const script = $("script[type=module]").attr("src") + const stylesheet = $("link[rel=stylesheet]").attr("href") + if (script === undefined || stylesheet === undefined) throw new Error("Missing island assets") + expect(script.startsWith(prefix + "assets/islands/")).toEqual(true) + expect(stylesheet.startsWith(prefix + "assets/islands/")).toEqual(true) + const scriptFile = outputAsset(script, route, base) + const styleFile = outputAsset(stylesheet, route, base) + expect(outputFiles).toContain(scriptFile) + expect(outputFiles).toContain(styleFile) + entries.push(scriptFile) + styles.push(styleFile) + const rules = cssRules(await readFile(join(outputDirectory, styleFile), "utf8")) + expect(rules).toEqual({ [".client_" + name]: { color } }) + const javascript = await readFile(join(outputDirectory, scriptFile), "utf8") + parseJavaScript(javascript, { ecmaVersion: 2020, sourceType: "module" }) + expect(javascript).toContain("client_" + name) + expect(javascript).toContain("\n") + })) + expect(new Set(styles).size).toEqual(2) + expect(new Set(entries).size).toEqual(2) + const sharedFiles = outputFiles.filter(file => file.includes("/chunks/") && file.endsWith(".js")) + expect(sharedFiles).toHaveLength(1) + const sharedFile = sharedFiles[0] + if (sharedFile === undefined) throw new Error("Missing shared module") + const sharedJavaScript = await readFile(join(outputDirectory, sharedFile), "utf8") + expect(sharedJavaScript).toContain('"defined"') + expect(sharedJavaScript).toContain('"transformed"') + expect(sharedJavaScript).toContain('"client-fixture"') + expect(sharedJavaScript).not.toContain("__CLIENT_TRANSFORM__") + const plain = loadHtml(await readFile(join(outputDirectory, "plain/index.html"), "utf8")) + expect(plain("body").text()).toEqual("No islands here") + expect(plain("script, link[rel=stylesheet]")).toHaveLength(0) + expect(outputFiles).not.toContain(".vite/solid-static-islands-manifest.json") + await assertJavaScriptFiles(outputDirectory) + } finally { + await rm(root, { force: true, recursive: true }) + } + }, 30_000) + + test("emits static fallback HTML with hashed JavaScript and deduplicated CSS links", async () => { + const root = await createFixture() + const outputDirectory = join(root, "dist") + try { + await build({ + build: { outDir: outputDirectory }, + logLevel: "silent", + plugins: [staticSitePlugins()], + root, + }) + const html = await readFile(join(outputDirectory, "index.html"), "utf8") + const $ = loadHtml(html) + expect($("#fallback").text()).toEqual("Static fallback") + expect($("#counter").text()).toEqual("Loading client island") + expect($("button")).toHaveLength(0) + expect(html).not.toContain("__SOLID_STATIC_ISLAND_") + const scripts = $("script[type=module][src]").map((_index, element) => $(element).attr("src")).get() + const styles = $("link[rel=stylesheet][href]").map((_index, element) => $(element).attr("href")).get() + expect(scripts).toHaveLength(1) + expect(scripts[0]).toMatch(/^\/assets\/islands\/[a-f0-9]+-[^/]+\.js$/) + expect(styles).toHaveLength(2) + expect(styles.filter(style => /^\/assets\/page-[^/]+\.css$/.test(style))).toHaveLength(1) + expect(styles.filter(style => /^\/assets\/islands\/[^/]+\.css$/.test(style))).toHaveLength(1) + const rules = Object.assign({}, ...await Promise.all(styles.map(async style => + cssRules(await readFile(join(outputDirectory, outputAsset(style, "index.html", "/")), "utf8")), + ))) + expect(rules["body"]).toEqual({ margin: "0" }) + expect(rules["#counter button"]?.["color"]).toMatch(/^(?:#010203|rgb\(1,\s*2,\s*3\))$/) + for (const script of scripts) await readFile(join(outputDirectory, outputAsset(script, "index.html", "/"))) + await assertJavaScriptFiles(outputDirectory) + } finally { + await rm(root, { force: true, recursive: true }) + } + }, 30_000) + + test("serves fallback HTML and transformed island assets during development", async () => { + const root = await createFixture() + const server = await createViteServer({ + appType: "spa", + logLevel: "silent", + plugins: [staticSitePlugins()], + root, + server: { hmr: false, host: "127.0.0.1", port: 0 }, + }) + try { + await server.listen() + const origin = server.resolvedUrls?.local[0] + if (origin === undefined) throw new Error("Missing Vite development URL") + const response = await fetch(origin, { headers: { accept: "text/html" }, signal: AbortSignal.timeout(5_000) }) + expect(response.status).toEqual(200) + const $ = loadHtml(await response.text()) + expect($("#fallback").text()).toEqual("Static fallback") + expect($("#counter").text()).toEqual("Loading client island") + const island = $("script[type=module][src='/src/counter-island.tsx']") + expect(island).toHaveLength(1) + const module = await fetch(new URL("/src/counter-island.tsx", origin), { signal: AbortSignal.timeout(5_000) }) + expect(module.status).toEqual(200) + expect(module.headers.get("content-type")).toContain("javascript") + const javascript = await module.text() + parseJavaScript(javascript, { ecmaVersion: "latest", sourceType: "module" }) + expect(javascript).toContain("#counter") + expect(javascript).toContain("Count ") + const css = await fetch(new URL("/src/counter.css?direct", origin), { signal: AbortSignal.timeout(5_000) }) + expect(css.status).toEqual(200) + expect(cssRules(await css.text())).toEqual({ "#counter button": { color: "rgb(1, 2, 3)" } }) + } finally { + await server.close() + await rm(root, { force: true, recursive: true }) + } + }, 30_000) +}) diff --git a/tsconfig.playwright.json b/tsconfig.playwright.json deleted file mode 100644 index c6089e9..0000000 --- a/tsconfig.playwright.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "types": ["node", "@playwright/test"] - }, - "include": ["compose/**/*.ts", "e2e/**/*.ts", "playwright.config.ts"] -}