import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { cp, mkdtemp, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { prepareCaptureReview } from "../../dist/review/capture-review.js"; import { createSourceCapturePlan } from "../../dist/source/capture-plan.js"; import { writeCiReportInput, exportCiReport } from "../../dist/report/artifact.js"; import { collectPlatformReports, deployExplorationReport, deployFixturePreview, deployLocalReports, FIXTURE_ALIAS_WAIT_MS, fixtureReports, deployPreview, previewProjectSlug, readReportZip, whoamiAccounts, zipReportDirectory } from "../../dist/report/preview-deploy.js"; import { spawnSync } from "node:child_process"; import { ZipReader, Uint8ArrayReader, TextWriter, configure } from "@zip.js/zip.js"; import { viewerLinks } from "../../dist/report/viewer-links.js"; import { loadReport } from "../../dist/report/agent-api.js"; import { manifestBody } from "../../dist/report/preview-manifest.js"; const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); const sha256 = createHash("sha256").update(png).digest("hex"); const pr = { repositoryId: "123", repository: "expo/explore-playground", number: 1, title: "", url: "https://github.com/expo/explore-playground/pull/1", base: { ref: "main", sha: "a".repeat(40) }, head: { ref: "feature/dynamic-branch", sha: "b".repeat(40) } }; const artifactUrl = "https://github.com/expo/explore-playground/actions/runs/10/artifacts/20"; const environment = { GITHUB_TOKEN: "test-token", GITHUB_RUN_ID: "10", GITHUB_RUN_ATTEMPT: "1", GITHUB_REPOSITORY: pr.repository }; async function fixture(t, { verified = true, preview = { hosting: "eas" }, presentation = pr } = {}) { const root = await mkdtemp(join(tmpdir(), "explore-artifact-ci-")); t.after(() => rm(root, { recursive: true, force: true })); const files = new Map([["app/index.tsx", "export default function Home() { return null; }"]]); const plan = createSourceCapturePlan({ baseSha: pr.base.sha, headSha: pr.head.sha, baseFiles: files, headFiles: files, changedFiles: [], mode: "all" }); const id = plan.surfaces[0].id; plan.surfaces[0].sourceChange = { contract: "expo.explore.source-change-summary.v1", baseSha: pr.base.sha, headSha: pr.head.sha, impact: "none", summary: "No source changes.", files: [], truncated: false }; for (const side of ["base", "head"]) { const directory = join(root, side); await mkdir(directory); await writeFile(join(directory, "home.png"), png); await writeFile(join(directory, "bundle.json"), JSON.stringify({ contract: "explore.bundle", side, gitCommit: pr[side].sha, easBuildId: "build", easProjectId: "project", profile: "explore:bundle", selectedSurfaceIds: [id], images: { [id]: { path: "home.png", sha256, width: 1, height: 1, sourceCommit: pr[side].sha, buildId: "build", capturedAt: "2026-09-15T00:00:00Z" } }, outcomes: { [id]: { status: "captured", attempts: 1, landing: verified ? "verified" : "unverified" } } })); } const prepared = await prepareCaptureReview({ plan, pullRequest: presentation, baseDirectory: join(root, "base"), headDirectory: join(root, "head"), runId: "10", runAttempt: "1", assertCurrentHead: async () => {}, ...(verified ? { assessments: { [id]: { review: { status: "unchanged", description: "No visible change" } } } } : {}) }); const inputPath = join(root, "report-input.json"); await writeCiReportInput({ inputPath, publicReview: prepared.review, assets: prepared.assets, commentState: { phase: "capturing", pullRequest: presentation, mode: "all", executionMode: "deterministic", configPath: ".expo-explore/config.yaml", startedAt: 1, updatedAt: 2, artifactOnly: true, links: viewerLinks({ repository: pr.repository, headSha: pr.head.sha, runId: "10", runAttempt: "1", configPath: ".expo-explore/config.yaml" }) }, ...(preview ? { preview } : {}) }); return { root, inputPath, prepared }; } configure({ useWebWorkers: false }); const TOKEN_RE = /^[a-z2-7]{32}$/; /** The fixture report as an explore:binary exploration the Worker (and so the deploy check) can open: exploration.json, same assets. */ async function asExploration(reportPath) { await rm(join(reportPath, "review.json")); await writeFile(join(reportPath, "exploration.json"), JSON.stringify({ kind: "explore.exploration-report", map: { screens: [] } })); } async function exportFixture(t, options) { const { root, inputPath } = await fixture(t, options); const reportPath = join(root, "report"); await exportCiReport({ inputPath, outputPath: reportPath }); const exportPath = join(root, "export"); await mkdir(join(exportPath, "client/viewer"), { recursive: true }); await mkdir(join(exportPath, "server/_expo"), { recursive: true }); await writeFile(join(exportPath, "client/viewer/index.html"), ""); await writeFile(join(exportPath, "server/_expo/routes.json"), "{}"); return { root, inputPath, reportPath, exportPath }; } /** A fake EAS CLI plus a fake deployment: `deploy` captures the sealed report and .env, then serves them like the Worker would. */ function easFixture({ origin = "https://acme-explore--1lcbdcpwn3.expo.app", aliasOrigin = "https://acme-explore--bluesky.expo.app", accounts = ["brent-org"], failDeploy = false, aliasServes = true, aliasListing, existingComment, manifestServes = true, robotsServes = true } = {}) { let deploys = 0; let latestComment = existingComment; const calls = []; const captured = {}; const run = (node, [bin, ...args], options) => { calls.push({ node, command: bin.split("/").at(-1), args: [...args], cwd: options.cwd, env: options.env }); if (args[0] === "whoami") return { status: 0, stdout: `Devin (robot) (authenticated using EXPO_TOKEN)\nAccounts:\n${accounts.map(account => `• ${account} (Role: Developer)`).join("\n")}\n`, stderr: "" }; if (args[0] === "init") { captured.appJson = JSON.parse(readFileSyncUtf8(join(options.cwd, "app.json"))); captured.packageJson = JSON.parse(readFileSyncUtf8(join(options.cwd, "package.json"))); return { status: 0, stdout: JSON.stringify({ id: "6dbf981e" }), stderr: "" }; } if (args[0] === "deploy") { deploys += 1; if (failDeploy === "alias-limit") return { status: 1, stdout: "", stderr: "✔ Created deployment\n✖ Failed to assign pr-7 alias to deployment\nThis account has reached the limit of 5 aliases from the Free plan. Upgrade your plan to raise the limit on the amount of aliases you can create for your deployments. https://expo.dev/accounts/acme/settings/billing\nRequest ID: 341b47a1\n Error: GraphQL request failed." }; // eas names the file whose upload failed, and every static file's path carries the link's token. if (failDeploy === "upload-path") { const token = /EXPLORE_PREVIEW_TOKEN=(\S+)/.exec(readFileSyncUtf8(join(options.cwd, ".env")))[1]; return { status: 1, stdout: "", stderr: `Upload of "client/p/${token}/assets/${sha256}.png" failed: 502\n Upload failed` }; } if (failDeploy === true || deploys <= failDeploy) return { status: 1, stdout: "", stderr: "Error: GraphQL request failed.\n Upload failed" }; captured.env = Object.fromEntries(readFileSyncUtf8(join(options.cwd, ".env")).trim().split("\n").map(line => line.split(/=(.*)/s).slice(0, 2))); captured.listing = captured.env.EXPLORE_PREVIEW_REPORTS ? JSON.parse(captured.env.EXPLORE_PREVIEW_REPORTS) : [{ platform: "ios", path: captured.env.EXPLORE_PREVIEW_REPORT }]; // The report's static files, by the path EAS Hosting serves each at. const tokenDirectory = join(options.cwd, "dist", "client", "p", captured.env.EXPLORE_PREVIEW_TOKEN); captured.files = Object.fromEntries(readdirTree(tokenDirectory).map(file => [`/p/${captured.env.EXPLORE_PREVIEW_TOKEN}/${file}`, readFileSyncBytes(join(tokenDirectory, file))])); captured.hasViewer = existsSyncLocal(join(options.cwd, "dist", "client", "viewer", "index.html")); captured.robots = existsSyncLocal(join(options.cwd, "dist", "client", "robots.txt")) ? readFileSyncUtf8(join(options.cwd, "dist", "client", "robots.txt")) : undefined; const alias = args.includes("--alias") ? args[args.indexOf("--alias") + 1] : undefined; captured.alias = alias; const aliases = alias ? [{ id: "al1", url: aliasOrigin, name: alias }] : undefined; return { status: 0, stdout: `${JSON.stringify({ dashboardUrl: "https://expo.dev/projects/x/hosting/deployments", identifier: "1lcbdcpwn3", url: origin, ...(aliases ? { aliases } : {}) })}\n`, stderr: "" }; } throw new Error(`Unexpected eas ${args.join(" ")}`); }; const requests = []; const transport = async (input, options = {}) => { const url = new URL(input); requests.push({ url, options }); // An alias that has not moved to the new deployment yet: it answers for an earlier token, so this one is 404. if (!aliasServes && url.origin === aliasOrigin) return new Response("Not found", { status: 404 }); if (url.origin === origin || (captured.alias && url.origin === aliasOrigin)) { // EAS Hosting serves a deployment's static file before the Worker sees the request. if (captured.files?.[url.pathname]) return new Response(captured.files[url.pathname], { status: 200 }); if (url.pathname === "/robots.txt" && captured.robots !== undefined && robotsServes) return new Response(captured.robots, { status: 200, headers: { "content-type": "text/plain" } }); const match = /^\/p\/([a-z2-7]{32})(?:\/(reports\.json|manifest\.json|api\/v1))?$/.exec(url.pathname); if (!captured.env || !match || match[1] !== captured.env.EXPLORE_PREVIEW_TOKEN) return new Response("Not found", { status: 404 }); // The token page negotiates like the real Worker: only a request that asks for HTML gets the shell. if (!match[2]) return (options.headers?.accept ?? "").includes("text/html") ? new Response("", { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }) : new Response("# Expo Explore report", { status: 200, headers: { "content-type": "text/markdown; charset=utf-8" } }); if (match[2] === "api/v1") return new Response(JSON.stringify({ kind: "pr-review" }), { status: 200, headers: { "content-type": "application/json; charset=utf-8" } }); // aliasListing: the alias still points at an earlier deployment of the same token and reports, listed the old way. if (match[2] === "reports.json" && aliasListing && url.origin === aliasOrigin) return new Response(aliasListing, { status: 200, headers: { "content-type": "application/json; charset=utf-8" } }); if (match[2] === "reports.json") return new Response(JSON.stringify(captured.listing.map(({ path: _path, ...rest }) => rest)), { status: 200, headers: { "content-type": "application/json; charset=utf-8" } }); // Selection as the Worker does it: ?target= by id, ?platform= by id and then by platform, else the first. const target = url.searchParams.get("target"), platform = url.searchParams.get("platform"); const entry = target ? captured.listing.find(candidate => candidate.target === target) : platform ? captured.listing.find(candidate => candidate.target === platform) ?? captured.listing.find(candidate => candidate.platform === platform) : captured.listing[0]; // The manifest the Viewer opens, from the target's static file; manifestServes: false is a Worker that cannot read it. const manifest = entry && manifestServes ? captured.files[entry.path] : undefined; return manifest ? new Response(manifest, { status: 200, headers: { "content-type": "application/json; charset=utf-8" } }) : new Response("Not found", { status: 404 }); } const artifactNames = { 20: "explore-review-10-1", 21: "explore-review-10-1-ios", 22: "explore-review-10-1-android" }; const byId = /\/actions\/artifacts\/(\d+)$/.exec(url.pathname); if (byId && artifactNames[byId[1]]) return Response.json({ id: Number(byId[1]), name: artifactNames[byId[1]], expired: false, workflow_run: { id: 10 } }); if (url.pathname.endsWith("/actions/runs/10/artifacts")) { const wanted = url.searchParams.get("name"); return Response.json({ artifacts: Object.entries(artifactNames).filter(([, name]) => name === wanted).map(([id, name]) => ({ id: Number(id), name, expired: false })) }); } if (url.pathname.endsWith("/actions/runs/10/attempts/1")) return Response.json({ id: 10, run_attempt: 1, status: "in_progress", conclusion: null }); if (url.pathname.endsWith("/pulls/1")) return Response.json({ state: "open", base: pr.base, head: { ...pr.head, repo: { full_name: pr.repository } } }); if (url.pathname.endsWith("/issues/1/comments") && options.method === "POST") { latestComment = JSON.parse(options.body).body; return Response.json({ id: 100 }); } if (url.pathname.endsWith("/issues/1/comments")) return Response.json(latestComment === undefined ? [] : [{ id: 100, body: latestComment, user: { login: "github-actions[bot]" } }]); if (url.pathname.endsWith("/issues/comments/100") && options.method === "PATCH") { latestComment = JSON.parse(options.body).body; return Response.json({ id: 100 }); } throw new Error(`Unexpected request ${url}`); }; return { run, transport, calls, requests, captured }; } import { readFileSync, existsSync, readdirSync } from "node:fs"; const readFileSyncUtf8 = path => readFileSync(path, "utf8"); const readFileSyncBytes = path => new Uint8Array(readFileSync(path)); const existsSyncLocal = path => existsSync(path); /** Every file under a directory, as paths relative to it. */ const readdirTree = (directory, prefix = "") => readdirSync(join(directory, prefix), { withFileTypes: true }) .flatMap(entry => entry.isDirectory() ? readdirTree(directory, join(prefix, entry.name)) : [join(prefix, entry.name)]); const deployEnvironment = { ...environment, EXPO_TOKEN: "expo-test-token" }; test("ci:deploy seals the exact report, deploys it with the preview export, verifies the gate, and links Explore report", async t => { const { inputPath, reportPath, exportPath } = await exportFixture(t); const eas = easFixture(); const secrets = []; const now = Date.parse("2026-09-11T03:07:00Z"); const result = await deployPreview({ inputPath, reportPath, artifactUrl, exportPath, environment: deployEnvironment, transport: eas.transport, run: eas.run, now: () => now, onSecret: secret => secrets.push(secret), verifyTimeoutMs: 1 }); assert.deepEqual(eas.calls.map(call => call.args[0]), ["whoami", "init", "deploy"]); assert.ok(eas.calls.every(call => call.node === process.execPath && call.command === "eas" && call.env.EXPO_TOKEN === "expo-test-token" && call.env.CI === "1")); assert.deepEqual(eas.calls[1].args, ["init", "--force", "--non-interactive", "--json"]); assert.deepEqual(eas.calls[2].args, ["deploy", "--json", "--non-interactive", "--export-dir", "dist"]); assert.deepEqual(eas.captured.appJson, { expo: { name: "explore-playground-explore", slug: "explore-playground-explore", owner: "brent-org", web: { output: "server" } } }); assert.equal(eas.captured.packageJson.private, true); assert.ok(eas.captured.hasViewer, "the preview export ships with the deployment"); const token = eas.captured.env.EXPLORE_PREVIEW_TOKEN; assert.match(token, TOKEN_RE); assert.deepEqual(secrets, [token]); // Nothing is sealed and nothing expires: the token in every path is what keeps the report private. assert.deepEqual(Object.keys(eas.captured.env).sort(), ["EXPLORE_PREVIEW_REPORT", "EXPLORE_PREVIEW_TOKEN"]); assert.equal(eas.captured.env.EXPLORE_PREVIEW_REPORT, `/p/${token}/targets/report.json`); assert.equal(result.previewUrl, `https://acme-explore--1lcbdcpwn3.expo.app/p/${token}`); assert.deepEqual([result.project, result.owner, result.deploymentId, result.expiresAt], ["explore-playground-explore", "brent-org", "1lcbdcpwn3", null]); const zip = await zipReportDirectory(reportPath); assert.equal(result.reportSha256, createHash("sha256").update(zip).digest("hex")); assert.equal(result.reportBytes, zip.byteLength); // The static files: the manifest as the Viewer opens it, stamped with this deployment, and every screenshot under its SHA-256. const loaded = await loadReport(zip); const manifest = JSON.parse(new TextDecoder().decode(eas.captured.files[`/p/${token}/targets/report.json`])); assert.match(manifest.deployment, /^[a-z2-7]{16}$/); assert.equal(new TextDecoder().decode(eas.captured.files[`/p/${token}/targets/report.json`]), manifestBody(loaded, manifest.deployment)); const assets = Object.keys(eas.captured.files).filter(path => path.includes("/assets/")); assert.deepEqual(assets.map(path => path.slice(`/p/${token}/`.length)).sort(), Object.keys(manifest.entries).sort()); for (const path of assets) assert.equal(createHash("sha256").update(eas.captured.files[path]).digest("hex"), /([a-f0-9]{64})\./.exec(path)[1]); // EAS Hosting cannot put noindex on static files, so robots.txt keeps crawlers off the screenshots and manifests, not the page. assert.equal(eas.captured.robots, "User-agent: *\nDisallow: /p/*/assets/\nDisallow: /p/*/targets/\n"); // Verification hit the gate and its negatives before the comment was published. const previewPaths = eas.requests.filter(request => request.url.origin === "https://acme-explore--1lcbdcpwn3.expo.app").map(request => request.url.pathname.replace(/[a-f0-9]{64}/, "")); const wrong = token.endsWith("a") ? `${token.slice(0, -1)}b` : `${token.slice(0, -1)}a`; // What the Viewer opens: the manifest, then one screenshot, a static file. assert.deepEqual(previewPaths, [`/p/${token}`, `/p/${token}/api/v1`, "/robots.txt", `/p/${token}/manifest.json`, `/p/${token}/assets/.png`, "/", `/p/${wrong}`, `/p/${wrong}/manifest.json`, `/p/${token}/viewer/index.html`]); assert.ok(eas.requests.every(request => request.url.origin !== "https://acme-explore--1lcbdcpwn3.expo.app" || request.options.cache === "no-store")); const posted = eas.requests.find(request => request.options.method === "POST"); const body = JSON.parse(posted.options.body).body; assert.match(body, new RegExp(`^### [^\\n]*\\[Review in Explore\\]\\(\\)$`, 'm')); assert.match(body, /Preview links work for anyone who has them until the deployment is deleted · /); assert.doesNotMatch(body, /explore-preview\.expo\.app\/\?/); assert.ok(eas.requests.indexOf(posted) > eas.requests.findIndex(request => request.url.pathname === `/p/${token}/viewer/index.html`), "publish after verification"); }); test("the report ZIP is deterministic and mirrors the GitHub artifact layout", async t => { const { reportPath } = await exportFixture(t); const first = await zipReportDirectory(reportPath); const second = await zipReportDirectory(reportPath); assert.deepEqual(first, second); const reader = new ZipReader(new Uint8ArrayReader(first)); const entries = await reader.getEntries(); assert.deepEqual(entries.map(entry => entry.filename).sort(), ["assets/" + sha256 + ".png", "review.json"]); const review = JSON.parse(await entries.find(entry => entry.filename === "review.json").getData(new TextWriter())); assert.equal(review.kind, "explore.report"); const videoName = "assets/" + "e".repeat(64) + ".mp4"; await writeFile(join(reportPath, videoName), "mp4"); const withVideo = await new ZipReader(new Uint8ArrayReader(await zipReportDirectory(reportPath))).getEntries(); assert.deepEqual(withVideo.map(entry => entry.filename).sort(), ["assets/" + sha256 + ".png", videoName, "review.json"], "launch recordings seal next to the screenshots"); await rm(join(reportPath, videoName)); await writeFile(join(reportPath, "index.html"), "