Files
opencode/packages/app/e2e/service-worker/cache.spec.ts

574 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { chromium, expect, test, type Page } from "@playwright/test"
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
import { createServer, type ServerResponse } from "node:http"
import type { AddressInfo } from "node:net"
import { once } from "node:events"
import { createHash } from "node:crypto"
import { join, extname, relative, sep } from "node:path"
import { tmpdir } from "node:os"
import { fileURLToPath } from "node:url"
import { build } from "vite"
import { onDemandAsset, serviceWorker } from "../../vite.pwa"
// The updater the web app ships (Settings › Updates and the titlebar), built into the fixture pages.
const updaterDriver = fileURLToPath(
new URL("../../../gui-plugins/src/updater/web-driver.ts", import.meta.url),
).replaceAll("\\", "/")
type Site = {
url: string
deploy: (fault?: "failed" | "html" | "corrupt" | "mixed-html" | "blocked") => void
legacy: () => void
requests: string[]
release: () => void
}
const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<string, Buffer>> }>({
builds: [
async ({}, use) => {
const directory = await mkdtemp(join(tmpdir(), "opencode-precache-"))
const builds: Record<string, Record<string, Buffer>> = {}
try {
for (const version of ["old", "new"]) {
const root = join(directory, version)
const outDir = join(root, "dist")
await mkdir(join(root, "public", "nested"), { recursive: true })
await Promise.all(
Object.entries({
"index.html": `<html><head></head><body><h1>Loading</h1><label>Draft<textarea></textarea></label><button>Load lazy</button><output></output><script type="module" src="/main.js"></script></body></html>`,
"main.js": `document.querySelector("h1").textContent = "${version}";
document.querySelector("button").onclick = async () => {
document.querySelector("output").textContent = await (await import("./lazy.js")).load()
};
// ?updater runs the web app's real update driver, with its actions as buttons.
if (location.search.includes("updater")) import(${JSON.stringify(updaterDriver)}).then(({ startWebUpdater }) => {
const state = Object.assign(document.createElement("p"), { ariaLabel: "Update state" });
const announced = Object.assign(document.createElement("p"), { ariaLabel: "Update offered" });
const updater = startWebUpdater({
changed: (next) => (state.textContent = next.status),
announce: () => (announced.textContent = "yes"),
report: () => {},
});
state.textContent = updater.state().status;
const action = (name, run) => Object.assign(document.createElement("button"), { textContent: name, onclick: run });
document.body.append(state, announced, action("Check for updates", updater.check), action("Update now", updater.apply), action("Reload from server", updater.reset));
});`,
"lazy.js": `export async function load() { return (await import("./nested.js")).value }`,
"nested.js": `export const value = "${version} nested lazy loaded"`,
"public/nested/data.json": JSON.stringify({ version }),
"public/nested/font.woff2": `font-${version}`,
"public/nested/module.wasm": Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]),
"public/large.bin": Buffer.alloc(2 * 1024 * 1024 + 1, version === "old" ? 1 : 2),
"public/_headers": "/*\n Cache-Control: no-cache",
"public/_redirects": "/* /index.html 200",
}).map(([path, contents]) => writeFile(join(root, path), contents)),
)
await build({
configFile: false,
root,
logLevel: "silent",
build: { outDir, assetsDir: "_assets", sourcemap: true },
plugins: serviceWorker(outDir),
})
builds[version] = Object.fromEntries(
await Promise.all(
(await readdir(outDir, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map(async (entry) => {
const path = join(entry.parentPath, entry.name)
return ["/" + relative(outDir, path).split(sep).join("/"), await readFile(path)]
}),
),
)
}
await use(builds)
} finally {
await rm(directory, { recursive: true, force: true })
}
},
{ scope: "worker" },
],
site: async ({ builds }, use) => {
const state = { version: "old", fault: "", legacy: false }
const requests: string[] = []
const blocked: ServerResponse[] = []
const release = () => blocked.splice(0).forEach((response) => response.end(builds.new["/large.bin"]))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://localhost")
const path = url.pathname
requests.push(path)
response.setHeader("cache-control", "no-store")
if (path === "/observer.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
if (path === "/api/info")
return void response
.writeHead(200, { "content-type": "application/json" })
.end(`{"version":"test","pid":1,"urls":["${url.origin}"],"paths":{"tmp":"/tmp/opencode"}}`)
if (path === "/sw.js" && state.legacy && state.version === "old") {
// Model the shipped worker's shared precache name and cache-first navigation behavior.
const urls = Object.keys(builds.old).filter(
(path) => path === "/index.html" || (path.startsWith("/_assets/") && path.endsWith(".js")),
)
response.setHeader("content-type", "text/javascript")
return void response.end(`
self.addEventListener("install", event => event.waitUntil(
caches.open("workbox-precache-v2-" + self.registration.scope).then(cache => cache.addAll(${JSON.stringify(urls)}))
));
self.addEventListener("fetch", event => event.respondWith(
caches.match(event.request.mode === "navigate" ? "/index.html" : event.request)
.then(response => response || fetch(event.request))
));
`)
}
if (path === "/index.html" && state.fault === "mixed-html")
return void response.writeHead(200, { "content-type": "text/html" }).end(builds.old["/index.html"])
if (path === "/large.bin" && state.fault && state.fault !== "mixed-html") {
if (state.fault === "blocked") return void blocked.push(response)
if (state.fault === "failed") return void response.writeHead(503).end("Unavailable")
if (state.fault === "html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<html>Wrong fallback</html>")
return void response.end("Incorrect bytes with a successful status")
}
const file = builds[state.version][path]
const types = new Map([
[".js", "text/javascript"],
[".html", "text/html"],
[".json", "application/json"],
[".wasm", "application/wasm"],
])
response.setHeader("content-type", types.get(extname(path)) ?? "application/octet-stream")
if (file) return void response.end(file)
if (extname(path)) return void response.writeHead(404).end("Not found")
response.setHeader("content-type", "text/html")
response.end(builds[state.version]["/index.html"])
})
server.listen(0, "127.0.0.1")
await once(server, "listening")
// SAFETY: a server listening on a host and port reports an AddressInfo; only pipe servers report a string.
const address = server.address() as AddressInfo
try {
await use({
url: `http://127.0.0.1:${address.port}`,
deploy: (fault = undefined) => {
state.version = "new"
state.fault = fault ?? ""
},
legacy: () => {
state.legacy = true
},
requests,
release,
})
} finally {
release()
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
},
})
async function install(page: Page, url: string) {
await page.goto(url)
await expect(page.getByRole("heading")).toHaveText("old")
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
await page.reload()
await expect.poll(() => page.evaluate(() => navigator.serviceWorker.controller?.state)).toBe("activated")
await expect(page.getByRole("heading")).toHaveText("old")
}
async function update(page: Page) {
return page.evaluateHandle(async () => {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) throw new Error("Missing installed worker")
const found = new Promise<ServiceWorker>((resolve) =>
registration.addEventListener(
"updatefound",
() => {
if (!registration.installing) throw new Error("Missing installing worker")
resolve(registration.installing)
},
{ once: true },
),
)
await registration.update()
return found
})
}
async function waiting(page: Page) {
await expect
.poll(() => page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.waiting?.state))
.toBe("installed")
}
fixture(
"opens an uncached route offline and executes never-used nested lazy chunks",
async ({ page, context, site }) => {
await install(page, site.url)
await expect(page.getByRole("status")).toBeEmpty()
await context.setOffline(true)
await page.goto(`${site.url}/workspace/never-visited`)
await expect(page.getByRole("heading")).toHaveText("old")
await page.getByRole("button", { name: "Load lazy" }).click()
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
},
)
fixture(
"precaches public files of every type and size, excluding deployment metadata and source maps",
async ({ page, site, builds, context }) => {
for (const output of Object.values(builds)) {
expect(output["/sw.js.map"]).toBeUndefined()
expect(Object.keys(output).some((path) => path.startsWith("/_assets/") && path.endsWith(".map"))).toBe(true)
}
await install(page, site.url)
const files = ["/nested/data.json", "/nested/font.woff2", "/nested/module.wasm", "/large.bin"]
await context.setOffline(true)
for (const path of files) {
const digest = await page.evaluate(
async (path) =>
Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await (await fetch(path)).arrayBuffer()))),
path,
)
expect(Buffer.from(digest)).toEqual(createHash("sha256").update(builds.old[path]).digest())
}
expect(site.requests).not.toContain("/_headers")
expect(site.requests).not.toContain("/_redirects")
expect(site.requests.filter((path) => path.endsWith(".map"))).toEqual([])
},
)
fixture(
"keeps drafts and removed old lazy chunks until every controlled tab closes",
async ({ page, context, site, builds }) => {
await install(page, site.url)
const second = await context.newPage()
await second.goto(site.url)
await expect(second.getByRole("heading")).toHaveText("old")
await second.getByLabel("Draft").fill("Keep this unsent prompt")
site.deploy()
const created = context.waitForEvent("serviceworker")
const worker = await update(page)
const replacement = await created
await waiting(page)
expect(await worker.evaluate((worker) => worker.state)).toBe("installed")
await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
await page.close()
await waiting(second)
await expect(second.getByRole("heading")).toHaveText("old")
await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
const removed = Object.keys(builds.old).find((path) => path.includes("/nested-") && path.endsWith(".js"))
expect(removed).toBeDefined()
expect((await second.request.get(`${site.url}${removed}`)).status()).toBe(404)
await second.getByRole("button", { name: "Load lazy" }).click()
await expect(second.getByRole("status")).toHaveText("old nested lazy loaded")
await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
await second.close()
await expect
.poll(() =>
replacement.evaluate(() => {
// This runs in the service worker, whose global scope holds its registration.
const registration =
"registration" in self && self.registration instanceof ServiceWorkerRegistration
? self.registration
: undefined
return { waiting: !!registration?.waiting, active: registration?.active?.state }
}),
)
.toEqual({ waiting: false, active: "activated" })
await context.setOffline(true)
const observer = await context.newPage()
await observer.goto(`${site.url}/workspace/reopened`)
await expect(observer.getByRole("heading")).toHaveText("new")
await observer.getByRole("button", { name: "Load lazy" }).click()
await expect(observer.getByRole("status")).toHaveText("new nested lazy loaded")
},
)
fixture(
"the updater offers a new build and moves every open tab to it with one click",
async ({ page, context, site }) => {
await install(page, `${site.url}/?updater`)
await expect(page.getByLabel("Update state")).toHaveText("idle")
const second = await context.newPage()
await second.goto(`${site.url}/?updater`)
await expect(second.getByLabel("Update state")).toHaveText("idle")
site.deploy()
await page.getByRole("button", { name: "Check for updates" }).click()
await expect(page.getByLabel("Update state")).toHaveText("ready")
await expect(page.getByLabel("Update offered")).toHaveText("yes")
// Every tab hears about the download, not only the one that checked.
await expect(second.getByLabel("Update state")).toHaveText("ready")
// A tab opened while the build waits offers it at once; before, it waited for every tab to close.
const third = await context.newPage()
await third.goto(`${site.url}/?updater`)
await expect(third.getByRole("heading")).toHaveText("old")
await expect(third.getByLabel("Update state")).toHaveText("ready")
await second.getByRole("button", { name: "Update now" }).click()
for (const tab of [page, second, third]) {
await expect(tab.getByRole("heading")).toHaveText("new")
await expect(tab.getByLabel("Update state")).toHaveText("idle")
}
await page.getByRole("button", { name: "Load lazy" }).click()
await expect(page.getByRole("status")).toHaveText("new nested lazy loaded")
},
)
fixture(
"reload from server replaces a saved build that cannot update, in every open tab",
async ({ page, context, site }) => {
await install(page, `${site.url}/?updater`)
const second = await context.newPage()
await second.goto(`${site.url}/?updater`)
await expect(second.getByLabel("Update state")).toHaveText("idle")
site.deploy("failed")
await page.getByRole("button", { name: "Check for updates" }).click()
await expect(page.getByLabel("Update state")).toHaveText("error")
await page.reload()
await expect(page.getByRole("heading")).toHaveText("old")
await page.getByRole("button", { name: "Reload from server" }).click()
await expect(page.getByRole("heading")).toHaveText("new")
expect(await page.evaluate(() => navigator.serviceWorker.controller)).toBeNull()
expect(await page.evaluate(async () => (await caches.keys()).length)).toBe(0)
// The other tab's old lazy chunks went with the caches, so it reloads too instead of failing its next import.
await expect(second.getByRole("heading")).toHaveText("new")
await second.getByRole("button", { name: "Load lazy" }).click()
await expect(second.getByRole("status")).toHaveText("new nested lazy loaded")
},
)
for (const fault of ["failed", "html", "corrupt", "mixed-html"] as const) {
fixture(`retains the old complete build when a precache download is ${fault}`, async ({ page, context, site }) => {
await install(page, site.url)
await page.getByLabel("Draft").fill("Still editing")
site.deploy(fault)
const worker = await update(page)
await expect.poll(() => worker.evaluate((worker) => worker.state)).toBe("redundant")
expect(await page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.waiting)).toBeNull()
await expect(page.getByLabel("Draft")).toHaveValue("Still editing")
await context.setOffline(true)
await page.goto(`${site.url}/workspace/after-failure`)
await expect(page.getByRole("heading")).toHaveText("old")
await page.getByRole("button", { name: "Load lazy" }).click()
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
})
}
fixture("does not expose new HTML while a precache download is blocked", async ({ page, context, site }) => {
await install(page, site.url)
site.requests.length = 0
site.deploy("blocked")
const worker = await update(page)
await expect.poll(() => site.requests.includes("/large.bin")).toBe(true)
expect(await worker.evaluate((worker) => worker.state)).toBe("installing")
const second = await context.newPage()
await second.goto(`${site.url}/workspace/during-install`)
await expect(second.getByRole("heading")).toHaveText("old")
site.release()
await waiting(page)
await second.reload()
await expect(second.getByRole("heading")).toHaveText("old")
})
fixture("upgrades the legacy shared precache only after old tabs close", async ({ page, context, site, builds }) => {
site.legacy()
const observer = await context.newPage()
await observer.goto(`${site.url}/observer.html`)
await install(page, site.url)
await page.getByLabel("Draft").fill("Legacy unsent prompt")
// A stale runtime-cache HTML response must not contaminate the new generated precache.
const entry = Object.keys(builds.new).find((path) => path.includes("/index-") && path.endsWith(".js"))
expect(entry).toBeDefined()
await page.evaluate(async (entry) => {
await (
await caches.open("opencode-assets")
).put(entry!, new Response("<html>stale fallback</html>", { headers: { "content-type": "text/html" } }))
}, entry)
site.deploy()
await update(page)
await waiting(page)
await expect(page.getByLabel("Draft")).toHaveValue("Legacy unsent prompt")
await page.getByRole("button", { name: "Load lazy" }).click()
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
await page.close()
await expect
.poll(() => observer.evaluate(async () => !!(await navigator.serviceWorker.getRegistration())?.waiting))
.toBe(false)
await context.setOffline(true)
await observer.goto(`${site.url}/workspace/legacy-upgraded`)
await expect(observer.getByRole("heading")).toHaveText("new")
await observer.getByRole("button", { name: "Load lazy" }).click()
await expect(observer.getByRole("status")).toHaveText("new nested lazy loaded")
})
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
await install(page, site.url)
const api = await page.goto(`${site.url}/api/info`)
expect(await api?.json()).toEqual({
version: "test",
pid: 1,
urls: ["http://localhost"],
paths: { tmp: "/tmp/opencode" },
})
expect(api?.fromServiceWorker()).toBe(false)
const asset = await page.goto(`${site.url}/_assets/missing.js`)
expect(asset?.status()).toBe(404)
expect(await asset?.text()).toBe("Not found")
})
test("the production build precaches every deployable file except on-demand Office assets", async ({
page,
context,
}) => {
const directory = new URL("../../dist/", import.meta.url)
const deployable = (await readdir(directory, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => "/" + relative(fileURLToPath(directory), join(entry.parentPath, entry.name)).split(sep).join("/"))
.filter((path) => !path.endsWith(".map") && !["/_headers", "/_redirects", "/sw.js"].includes(path))
const files = deployable.filter((path) => !onDemandAsset(path))
expect(files.length).toBeGreaterThan(1)
// The Office engines ship but load on demand, so a build without them would make this exclusion vacuous.
expect(deployable.some((path) => path.endsWith(".wasm") && onDemandAsset(path))).toBe(true)
const server = createServer(async (request, response) => {
const path = new URL(request.url ?? "/", "http://localhost").pathname
response.setHeader("cache-control", "no-store")
if (path === "/probe.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Precache probe</title>")
const bytes = await readFile(new URL(`.${path}`, directory)).catch(() => undefined)
if (!bytes) return void response.writeHead(404).end("Not found")
if (path.endsWith(".js")) response.setHeader("content-type", "text/javascript")
if (path.endsWith(".html")) {
response.setHeader("content-type", "text/html")
// Inspect the real cached HTML without executing the app or contacting a backend.
response.setHeader("content-security-policy", "default-src 'none'")
}
response.end(bytes)
})
server.listen(0, "127.0.0.1")
await once(server, "listening")
// SAFETY: a server listening on a host and port reports an AddressInfo; only pipe servers report a string.
const address = server.address() as AddressInfo
const url = `http://127.0.0.1:${address.port}`
try {
await page.goto(`${url}/probe.html`)
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
const cached = await page.evaluate(async () =>
(
await Promise.all(
(await caches.keys()).map(async (name) =>
(await (await caches.open(name)).keys()).map((request) => new URL(request.url).pathname),
),
)
)
.flat()
.sort(),
)
expect(cached).toEqual(files.sort())
await context.setOffline(true)
const response = await page.goto(`${url}/workspace/offline-probe`)
expect(response?.fromServiceWorker()).toBe(true)
expect(await response?.text()).toBe(await readFile(new URL("index.html", directory), "utf8"))
} finally {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
})
test("the production build installs as an app behind an authenticating proxy", async () => {
const directory = new URL("../../dist/", import.meta.url)
const server = createServer(async (request, response) => {
// Model a reverse proxy that rejects every request without the signed-in user's cookie.
if (!request.headers.cookie?.includes("session=signed-in")) return void response.writeHead(401).end()
const path = new URL(request.url ?? "/", "http://localhost").pathname
const file = extname(path) ? path : "/index.html"
const bytes = await readFile(new URL(`.${file}`, directory)).catch(() => undefined)
if (!bytes) return void response.writeHead(404).end("Not found")
// Check the real HTML without starting the app or its service worker.
if (file === "/index.html")
response.writeHead(200, { "content-type": "text/html", "content-security-policy": "script-src 'none'" })
response.end(bytes)
})
server.listen(0, "127.0.0.1")
await once(server, "listening")
// SAFETY: a server listening on a host and port reports an AddressInfo; only pipe servers report a string.
const address = server.address() as AddressInfo
const url = `http://127.0.0.1:${address.port}`
// Chrome Headless Shell has no install checks and reports no errors for any page, and an incognito context always
// reports one, so check in a persistent Chrome profile.
const context = await chromium.launchPersistentContext("", { channel: "chromium" })
try {
await context.addCookies([{ name: "session", value: "signed-in", url }])
const page = await context.newPage()
await page.goto(url)
const session = await context.newCDPSession(page)
expect((await session.send("Page.getInstallabilityErrors")).installabilityErrors).toEqual([])
} finally {
await context.close()
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
})