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"), "