diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1ef9173..e9fffc0 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 @@ -53,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/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/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..3f0690e 100644 --- a/README.md +++ b/README.md @@ -248,14 +248,26 @@ Install Node.js 24, Nub 0.9, and Moon 2.5.5, then run: ```sh nub install --frozen-lockfile -nubx playwright install chromium 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. +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 +`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 +276,11 @@ 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. 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/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 4df04f4..0000000 --- a/tsconfig.playwright.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "types": ["node", "@playwright/test"] - }, - "include": ["e2e/**/*.ts", "playwright.config.ts"] -}