@valbuild/cli 0.137.0 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import zlib from "zlib";
5
+ import type { BuildTarget, PublishArtifact } from "@valbuild/tanstack-build";
6
+ import type { VendorLayer } from "@valbuild/tanstack-build/node";
7
+
8
+ /**
9
+ * `val publish`'s build: the site, built from the checkout, as the artifacts
10
+ * the publish API takes (valbuild/home, docs/app-mode.md, "Briefing:
11
+ * val-publish" and "Flow B").
12
+ *
13
+ * The same builder the Studio runs in a tab -- `@valbuild/tanstack-build` --
14
+ * plus the half a tab cannot run: the project's dependency layer, built from
15
+ * its own `node_modules` (`@valbuild/tanstack-build/node`). So a CI checkout
16
+ * and a Studio build of the same content produce the same site, and the layer
17
+ * is the one thing only this side can make.
18
+ *
19
+ * Against content's build target, never the loader's: content is the only
20
+ * address a publisher has, and it answers what the platform expects.
21
+ *
22
+ * Written to a directory, and then published by the upload path `val publish
23
+ * --artifacts` has always had: one path for bytes to leave by, whoever built
24
+ * them.
25
+ */
26
+
27
+ /** What `val publish` hands on to the upload. */
28
+ export type BuiltArtifacts =
29
+ | {
30
+ status: "built";
31
+ /** A directory of artifacts, laid out as `--artifacts` reads them. */
32
+ dir: string;
33
+ buildHash: string;
34
+ linksOwnCss: boolean;
35
+ /**
36
+ * The layer by name, when content already holds it and it was not
37
+ * written; `null` when the project declares nothing beyond the base set.
38
+ * `undefined` when the layer artifact is in `dir`, which names it.
39
+ */
40
+ layerRev?: string | null;
41
+ warnings: string[];
42
+ }
43
+ | { status: "error"; message: string };
44
+
45
+ type TanstackBuild = typeof import("@valbuild/tanstack-build");
46
+ type TanstackBuildNode = typeof import("@valbuild/tanstack-build/node");
47
+
48
+ /**
49
+ * The builder, loaded only when a build is asked for: it brings rolldown's
50
+ * native binary with it, and `val validate` has no use for one.
51
+ */
52
+ async function loadBuilder(): Promise<
53
+ | { status: "ok"; build: TanstackBuild; node: TanstackBuildNode }
54
+ | { status: "error"; message: string }
55
+ > {
56
+ try {
57
+ const [build, node] = await Promise.all([
58
+ import("@valbuild/tanstack-build"),
59
+ import("@valbuild/tanstack-build/node"),
60
+ ]);
61
+ return { status: "ok", build, node };
62
+ } catch (e) {
63
+ return {
64
+ status: "error",
65
+ message:
66
+ "`val publish` builds the site with @valbuild/tanstack-build and rolldown, and " +
67
+ `they could not be loaded: ${e instanceof Error ? e.message : String(e)}\n\n` +
68
+ "Install them in the project:\n\n" +
69
+ " npm install --save-dev @valbuild/tanstack-build rolldown @tanstack/router-generator @tanstack/router-plugin\n\n" +
70
+ "or publish a build made elsewhere with --artifacts <dir>.",
71
+ };
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Never the project's own files, at any depth: installed packages. Dot
77
+ * directories (`.git`, `.tanstack`, `.val`, `.output`) are tooling too.
78
+ */
79
+ const IGNORED_DIRS = new Set(["node_modules"]);
80
+
81
+ /**
82
+ * Build output, at the project's root only: `src/routes/build/` is a route
83
+ * called "build", not a previous build.
84
+ */
85
+ const ROOT_OUTPUT_DIRS = new Set(["dist", "build"]);
86
+
87
+ /** Lockfiles are JSON, and nothing imports them. */
88
+ const ROOT_LOCKFILES = new Set(["package-lock.json"]);
89
+
90
+ const CODE_FILE = /\.(tsx?|jsx?|mjs|cjs|css)$/;
91
+
92
+ /**
93
+ * JSON is the project's too: `tsconfig.json` and `tsr.config.json` are read
94
+ * by the build, a `.val.json` holds a `.jsonValues()` entry no import points
95
+ * at, and a module may import one -- the starter's `val.server.ts` imports
96
+ * `.prettierrc.json`, which is also why a dot FILE is read.
97
+ */
98
+ const JSON_FILE = /\.json$/;
99
+
100
+ /**
101
+ * The project's own files: its source as text, and the binaries it imports
102
+ * (an image, a font) base64, as `BuildInput.assets` takes them. `public/` is
103
+ * neither: it is served as is, and read by `readPublicFiles`.
104
+ */
105
+ export function readProjectFiles(
106
+ root: string,
107
+ isAsset: (path: string) => boolean,
108
+ ): { sources: Record<string, string>; assets: Record<string, string> } {
109
+ const sources: Record<string, string> = {};
110
+ const assets: Record<string, string> = {};
111
+ const walk = (dir: string) => {
112
+ let entries: fs.Dirent[];
113
+ try {
114
+ entries = fs.readdirSync(dir, { withFileTypes: true });
115
+ } catch {
116
+ return;
117
+ }
118
+ for (const entry of entries) {
119
+ const full = path.join(dir, entry.name);
120
+ if (entry.isDirectory()) {
121
+ if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name))
122
+ continue;
123
+ if (dir === root && ROOT_OUTPUT_DIRS.has(entry.name)) continue;
124
+ if (dir === root && entry.name === "public") continue;
125
+ walk(full);
126
+ continue;
127
+ }
128
+ const key = path.relative(root, full).split(path.sep).join("/");
129
+ if (CODE_FILE.test(entry.name)) {
130
+ sources[key] = fs.readFileSync(full, "utf8");
131
+ } else if (JSON_FILE.test(entry.name)) {
132
+ if (!ROOT_LOCKFILES.has(key)) {
133
+ sources[key] = fs.readFileSync(full, "utf8");
134
+ }
135
+ } else if (isAsset(key)) {
136
+ assets[key] = fs.readFileSync(full).toString("base64");
137
+ }
138
+ }
139
+ };
140
+ walk(root);
141
+ return { sources, assets };
142
+ }
143
+
144
+ /** Files under `public/`, base64, keyed `public/<path>` as the build takes them. */
145
+ export function readPublicFiles(root: string): Record<string, string> {
146
+ const out: Record<string, string> = {};
147
+ const base = path.join(root, "public");
148
+ const walk = (dir: string) => {
149
+ let entries: fs.Dirent[];
150
+ try {
151
+ entries = fs.readdirSync(dir, { withFileTypes: true });
152
+ } catch {
153
+ return;
154
+ }
155
+ for (const entry of entries) {
156
+ const full = path.join(dir, entry.name);
157
+ if (entry.isDirectory()) walk(full);
158
+ else {
159
+ const key = path.relative(root, full).split(path.sep).join("/");
160
+ out[key] = fs.readFileSync(full).toString("base64");
161
+ }
162
+ }
163
+ };
164
+ walk(base);
165
+ return out;
166
+ }
167
+
168
+ /** The project's build-time variables: `PUBLIC_*` reach the browser, by the builder's rule. */
169
+ function publicEnv(env: NodeJS.ProcessEnv): Record<string, string> {
170
+ const out: Record<string, string> = {};
171
+ for (const [key, value] of Object.entries(env)) {
172
+ if (key.startsWith("PUBLIC_") && value !== undefined) out[key] = value;
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * The target, with this project's layer as built here rather than as content
179
+ * last stored it: a build has to link against the layer it ships with.
180
+ */
181
+ export function targetWithLayer(
182
+ target: BuildTarget,
183
+ layer: VendorLayer | null,
184
+ ): BuildTarget {
185
+ return {
186
+ ...target,
187
+ project: {
188
+ ...target.project,
189
+ rev: layer?.rev ?? null,
190
+ modules: layer?.modules ?? {},
191
+ css: layer?.css ?? {},
192
+ workerOnly: layer?.workerOnly ?? [],
193
+ },
194
+ };
195
+ }
196
+
197
+ function writeArtifact(dir: string, key: string, body: string | Buffer) {
198
+ const file = path.join(dir, ...key.split("/"));
199
+ fs.mkdirSync(path.dirname(file), { recursive: true });
200
+ fs.writeFileSync(file, body);
201
+ }
202
+
203
+ export async function buildArtifacts(options: {
204
+ root: string;
205
+ target: BuildTarget;
206
+ /** The project, as the platform names it; only for the wiring's record. */
207
+ project: string;
208
+ /** Content, which is the platform as far as this publisher knows. */
209
+ contentHost: string;
210
+ /** The commit this checkout is at, and the branch it publishes. */
211
+ git: { commit: string; branch: string } | null;
212
+ env?: NodeJS.ProcessEnv;
213
+ log?: (line: string) => void;
214
+ /** Where to write; a fresh temporary directory by default. */
215
+ outDir?: string;
216
+ }): Promise<BuiltArtifacts> {
217
+ const log = options.log ?? (() => {});
218
+ const loaded = await loadBuilder();
219
+ if (loaded.status === "error") return loaded;
220
+ const { build: builder, node } = loaded;
221
+ const { root, target } = options;
222
+ const warnings: string[] = [];
223
+
224
+ try {
225
+ /*
226
+ * Wired for the platform at publish time, as the Studio's builds are: the
227
+ * wired `val.server.ts` reads its files from a module the BUILD emits, so
228
+ * no checkout can carry it. `rebakeGit` would be the tab's spelling of the
229
+ * same thing for a record that is already wired.
230
+ */
231
+ const { sources: onDisk, assets } = readProjectFiles(root, builder.isAsset);
232
+ const wiring =
233
+ builder.isValProject(onDisk) && !builder.isWired(onDisk)
234
+ ? builder.wireUp(onDisk, {
235
+ project: options.project,
236
+ loader: options.contentHost,
237
+ ...(options.git ? { git: options.git } : {}),
238
+ })
239
+ : null;
240
+ // Less the one about the layer, which is the thing this goes on to build.
241
+ warnings.push(
242
+ ...(wiring?.warnings ?? []).filter(
243
+ (warning) => !warning.includes("dependency layer built for it"),
244
+ ),
245
+ );
246
+ const sources = wiring?.files ?? onDisk;
247
+
248
+ // The dependency layer: from what the project imports, of what it declares.
249
+ log("Building the dependency layer");
250
+ const manifest: { dependencies?: Record<string, string> } = (() => {
251
+ try {
252
+ return JSON.parse(sources["package.json"] ?? "{}");
253
+ } catch {
254
+ return {};
255
+ }
256
+ })();
257
+ const deps = node.layerDepsOf(manifest, sources);
258
+ const css = await node.collectDependencyCss(
259
+ root,
260
+ node.cssSpecifiersOf(sources),
261
+ );
262
+ const layer = await node.buildVendorLayer(root, deps, css, {
263
+ base: target.base,
264
+ // A project on the platform layers its own copies; only the platform's
265
+ // own checkout carries pinned ones.
266
+ resolvePlatformModule: () => null,
267
+ // No built base layer here: the export audit is skipped, not failed.
268
+ baseLayerDir: null,
269
+ });
270
+
271
+ log("Building the site");
272
+ const fileBased = await node.isFileBased(root);
273
+ const files = fileBased
274
+ ? await node.generateRouteTree(root, sources)
275
+ : sources;
276
+ /*
277
+ * The project's copy of the splitter first, then the one beside this CLI
278
+ * -- where a package manager puts an optional peer the project installed
279
+ * but did not hoist. Neither is a build that does not split, which costs
280
+ * size and not behaviour.
281
+ */
282
+ const splitter =
283
+ (await node.routeSplitter(root)) ??
284
+ (typeof __dirname === "string"
285
+ ? await node.routeSplitter(__dirname)
286
+ : null);
287
+ if (splitter === null) {
288
+ warnings.push(
289
+ "Routes are not code-split: @tanstack/router-plugin is not installed.",
290
+ );
291
+ }
292
+ const built = await builder.buildUserApp({
293
+ files,
294
+ target: targetWithLayer(target, layer),
295
+ env: publicEnv(options.env ?? process.env),
296
+ publicFiles: readPublicFiles(root),
297
+ assets,
298
+ // The project's own files, without the generated tree: what the next
299
+ // publish -- the Studio's included -- starts from.
300
+ projectSource: sources,
301
+ loadCssModule: node.cssModuleLoader(root),
302
+ ...(splitter ? { routeSplitter: splitter } : {}),
303
+ rsc: target.project.rsc,
304
+ });
305
+ const artifacts: PublishArtifact[] = await builder.publishArtifacts(built, {
306
+ projectSource: sources,
307
+ });
308
+
309
+ const dir =
310
+ options.outDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "val-publish-"));
311
+ for (const artifact of artifacts) {
312
+ writeArtifact(dir, artifact.key, artifact.body);
313
+ }
314
+ /*
315
+ * The layer: sent when content does not hold this one, named when it
316
+ * does. The rev is a digest of the built chunks, so "content has this" is
317
+ * a fact rather than a hope.
318
+ */
319
+ let layerRev: string | null | undefined;
320
+ if (layer === null) layerRev = null;
321
+ else if (layer.rev === target.project.rev) layerRev = layer.rev;
322
+ else writeArtifact(dir, "layer", zlib.gzipSync(JSON.stringify(layer)));
323
+
324
+ return {
325
+ status: "built",
326
+ dir,
327
+ buildHash: built.hash,
328
+ linksOwnCss: built.linksOwnCss,
329
+ ...(layerRev !== undefined ? { layerRev } : {}),
330
+ warnings,
331
+ };
332
+ } catch (e) {
333
+ return {
334
+ status: "error",
335
+ message: `The site could not be built: ${
336
+ e instanceof Error ? e.message.split("\n")[0] : String(e)
337
+ }`,
338
+ };
339
+ }
340
+ }
@@ -0,0 +1,108 @@
1
+ import { execFileSync } from "child_process";
2
+ import path from "path";
3
+
4
+ /**
5
+ * `val publish` with no `--artifacts` builds the checkout, and publishes it.
6
+ *
7
+ * What a connected project's CI does on every push to its branch: the runner
8
+ * has a checkout and `node_modules`, content has the build target, and nothing
9
+ * else is needed. So this drives the whole command -- the build target asked
10
+ * of content, the dependency layer built from `node_modules`, the route tree
11
+ * generated, the site built, the artifacts declared and uploaded -- against
12
+ * the fake content service, twice: once with no layer held, once with the
13
+ * layer the first run sent.
14
+ *
15
+ * The build runs in a child process because `@rolldown/browser` is ESM-only
16
+ * and jest runs CommonJS here. See the probe beside this file.
17
+ */
18
+ type Declared = {
19
+ buildHash: string;
20
+ commit: string | null;
21
+ branch: string | null;
22
+ layerRev: string | null;
23
+ linksOwnCss: boolean | null;
24
+ keys: string[];
25
+ };
26
+ type Run = {
27
+ result: { status: string; artifacts?: number };
28
+ declared: Declared[];
29
+ buildTargetCalls: number;
30
+ uploadedLayerRev: string | null;
31
+ sourceHasRoute: boolean;
32
+ clientHasPage: boolean;
33
+ clientHasLogo: boolean;
34
+ buildRouteBuilt: boolean;
35
+ checkoutUntouched: boolean;
36
+ };
37
+
38
+ let probe: { fresh: Run; held: Run };
39
+
40
+ beforeAll(() => {
41
+ const out = execFileSync(
42
+ path.join(__dirname, "..", "..", "node_modules", ".bin", "tsx"),
43
+ [path.join(__dirname, "__fixtures__", "buildPublishProbe.ts")],
44
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
45
+ );
46
+ // tsx's WASI warnings can share stdout; take the last line that parses.
47
+ const line = out
48
+ .trim()
49
+ .split("\n")
50
+ .reverse()
51
+ .find((candidate) => candidate.startsWith("{"));
52
+ probe = JSON.parse(line!) as typeof probe;
53
+ }, 300_000);
54
+
55
+ test("asks content what to build against, and publishes what it built", () => {
56
+ const { fresh } = probe;
57
+ expect(fresh.result.status).toBe("live");
58
+ expect(fresh.buildTargetCalls).toBe(1);
59
+ expect(fresh.declared).toHaveLength(1);
60
+ const [declared] = fresh.declared;
61
+ expect(declared!.commit).toBe("1234567890abcdef1234567890abcdef12345678");
62
+ expect(declared!.branch).toBe("main");
63
+ expect(declared!.keys).toEqual(
64
+ expect.arrayContaining([
65
+ "server",
66
+ "client",
67
+ "css",
68
+ "source",
69
+ "public/robots.txt",
70
+ ]),
71
+ );
72
+ expect(fresh.clientHasPage).toBe(true);
73
+ });
74
+
75
+ test("the stored source is the project's own files", () => {
76
+ expect(probe.fresh.sourceHasRoute).toBe(true);
77
+ });
78
+
79
+ test("an image the project imports reaches the build", () => {
80
+ expect(probe.fresh.clientHasLogo).toBe(true);
81
+ });
82
+
83
+ test("a folder called `build` below the root is source, not output", () => {
84
+ expect(probe.fresh.buildRouteBuilt).toBe(true);
85
+ });
86
+
87
+ test("a layer content does not hold is built and sent, and named by its own rev", () => {
88
+ const { fresh } = probe;
89
+ expect(fresh.declared[0]!.keys).toContain("layer");
90
+ expect(fresh.uploadedLayerRev).toEqual(expect.any(String));
91
+ expect(fresh.declared[0]!.layerRev).toBe(fresh.uploadedLayerRev);
92
+ });
93
+
94
+ test("a layer content already holds is named, not sent again", () => {
95
+ const { fresh, held } = probe;
96
+ expect(held.result.status).toBe("live");
97
+ expect(held.declared[0]!.keys).not.toContain("layer");
98
+ expect(held.declared[0]!.layerRev).toBe(fresh.uploadedLayerRev);
99
+ // The same checkout is the same build, and so the same publish.
100
+ expect(held.declared[0]!.buildHash).toBe(fresh.declared[0]!.buildHash);
101
+ });
102
+
103
+ test("the checkout is left as it was found", () => {
104
+ // The route tree is generated on disk and put back; the build is written
105
+ // to a temporary directory, not into `.val/publish`.
106
+ expect(probe.fresh.checkoutUntouched).toBe(true);
107
+ expect(probe.held.checkoutUntouched).toBe(true);
108
+ });
@@ -2,6 +2,7 @@ import fs from "fs";
2
2
  import { getJson, postJson } from "./contentHost";
3
3
  import {
4
4
  ArtifactsResponse,
5
+ BuildTargetResponse,
5
6
  DeclareBody,
6
7
  DeclareResponse,
7
8
  PromoteResponse,
@@ -9,6 +10,7 @@ import {
9
10
  UploadSlot,
10
11
  VerifyResponse,
11
12
  parseArtifacts,
13
+ parseBuildTarget,
12
14
  parseDeclare,
13
15
  parsePromote,
14
16
  parseStatus,
@@ -16,6 +18,8 @@ import {
16
18
  } from "@valbuild/shared/internal";
17
19
 
18
20
  export type PublishClient = {
21
+ /** What to build against: the platform's base and this project's layer. */
22
+ buildTarget(): Promise<BuildTargetResponse>;
19
23
  declare(body: DeclareBody): Promise<DeclareResponse>;
20
24
  confirmArtifacts(publishId: string): Promise<ArtifactsResponse>;
21
25
  verify(publishId: string): Promise<VerifyResponse>;
@@ -38,6 +42,10 @@ export function createPublishClient(options: {
38
42
  `${host}/v1/publish/${encodeURIComponent(publishId)}${step ? `/${step}` : ""}`;
39
43
 
40
44
  return {
45
+ buildTarget: async () =>
46
+ parseBuildTarget(
47
+ await getJson({ url: `${host}/v1/build-target`, headers, fetchImpl }),
48
+ ),
41
49
  declare: async (body) =>
42
50
  parseDeclare(
43
51
  await postJson({ url: `${host}/v1/publish`, headers, body, fetchImpl }),
@@ -1,6 +1,6 @@
1
1
  import crypto from "crypto";
2
2
  import http from "http";
3
- import { PublishProblem } from "@valbuild/shared/internal";
3
+ import { BuildTargetResponse, PublishProblem } from "@valbuild/shared/internal";
4
4
 
5
5
  /**
6
6
  * A local stand-in for the publish API on content.val.build.
@@ -50,6 +50,50 @@ export type FakeContentOptions = {
50
50
  * worth being able to drive from both sides.
51
51
  */
52
52
  head?: string;
53
+ /** What `GET /v1/build-target` answers. {@link FAKE_BUILD_TARGET} by default. */
54
+ buildTarget?: BuildTargetResponse;
55
+ };
56
+
57
+ /**
58
+ * The smallest platform a build accepts: the base set, and no project layer
59
+ * yet -- a project that has never published one.
60
+ */
61
+ export const FAKE_BUILD_TARGET: BuildTargetResponse = {
62
+ base: {
63
+ rev: "base",
64
+ shellRev: "shell",
65
+ rscShellRev: "rsc",
66
+ // The platform's base set, as its vendor package declares it.
67
+ modules: {
68
+ react: "react",
69
+ "react/jsx-runtime": "jsx-runtime",
70
+ "react/jsx-dev-runtime": "jsx-dev-runtime",
71
+ "react-dom": "react-dom",
72
+ "react-dom/client": "react-dom-client",
73
+ "react-dom/server": "react-dom-server",
74
+ "@tanstack/react-router": "tanstack-react-router",
75
+ "@tanstack/react-start": "tanstack-react-start",
76
+ "@tanstack/react-start/server-rpc": "tanstack-react-start-server-rpc",
77
+ "@tanstack/react-start/client-rpc": "tanstack-react-start-client-rpc",
78
+ "@tanstack/router-core": "tanstack-router-core",
79
+ "@tanstack/start-client-core": "tanstack-start-client-core",
80
+ "@tanstack/start-storage-context": "tanstack-start-storage-context",
81
+ "@tanstack/history": "tanstack-history",
82
+ "@tanstack/store": "tanstack-store",
83
+ "@tanstack/react-store": "tanstack-react-store",
84
+ },
85
+ rscModules: {},
86
+ paths: {
87
+ baseFromProject: "../vendor/",
88
+ projectVendorDir: "pvendor",
89
+ rscVendorBase: "../vendor-rsc/",
90
+ rscRuntimeSpecifier: "@tanstack/react-start/rsc",
91
+ rscRuntimePath: "./runtime.js",
92
+ flightServer: "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge",
93
+ serverFnBase: "/_serverFn/",
94
+ },
95
+ },
96
+ project: { rev: null, rsc: false, modules: {}, css: {}, workerOnly: [] },
53
97
  };
54
98
 
55
99
  export type FakeContentService = {
@@ -166,10 +210,6 @@ export async function startFakeContentService(
166
210
  return;
167
211
  }
168
212
 
169
- if (parts[1] !== "publish") {
170
- send(res, 404, { statusCode: 404, message: "No such route" });
171
- return;
172
- }
173
213
  if (!authorization?.startsWith("Bearer val_pt_")) {
174
214
  send(res, 401, {
175
215
  statusCode: 401,
@@ -188,6 +228,21 @@ export async function startFakeContentService(
188
228
  return;
189
229
  }
190
230
 
231
+ // GET /v1/build-target - what a build links against.
232
+ if (
233
+ parts.length === 2 &&
234
+ parts[1] === "build-target" &&
235
+ req.method === "GET"
236
+ ) {
237
+ send(res, 200, options.buildTarget ?? FAKE_BUILD_TARGET);
238
+ return;
239
+ }
240
+
241
+ if (parts[1] !== "publish") {
242
+ send(res, 404, { statusCode: 404, message: "No such route" });
243
+ return;
244
+ }
245
+
191
246
  // POST /v1/publish - declare.
192
247
  if (parts.length === 2 && req.method === "POST") {
193
248
  const body = parseJson(await readBody(req));
@@ -0,0 +1,110 @@
1
+ import os from "os";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import { ciReportFromEnv, reportCiRun } from "./reportCiRun";
5
+
6
+ const actions = {
7
+ GITHUB_SHA: "abc123",
8
+ GITHUB_REF_NAME: "main",
9
+ GITHUB_SERVER_URL: "https://github.com",
10
+ GITHUB_REPOSITORY: "acme/site",
11
+ GITHUB_RUN_ID: "42",
12
+ };
13
+
14
+ describe("what a CI report says", () => {
15
+ test("takes the commit, the branch and the run from GitHub Actions", () => {
16
+ expect(ciReportFromEnv({ status: "failed" }, actions)).toEqual({
17
+ status: "ok",
18
+ report: {
19
+ status: "failed",
20
+ commit: "abc123",
21
+ branch: "main",
22
+ url: "https://github.com/acme/site/actions/runs/42",
23
+ },
24
+ });
25
+ });
26
+
27
+ test("a flag wins over the environment", () => {
28
+ const read = ciReportFromEnv(
29
+ { status: "succeeded", commit: "def456", url: "https://ci.example/1" },
30
+ actions,
31
+ );
32
+ expect(read).toMatchObject({
33
+ report: { commit: "def456", url: "https://ci.example/1" },
34
+ });
35
+ });
36
+
37
+ test("a status that is neither, or no commit, is refused by name", () => {
38
+ expect(ciReportFromEnv({ status: "passed" }, actions)).toMatchObject({
39
+ status: "error",
40
+ message: expect.stringContaining('"passed"'),
41
+ });
42
+ expect(ciReportFromEnv({ status: "failed" }, {})).toMatchObject({
43
+ status: "error",
44
+ message: expect.stringContaining("GITHUB_SHA"),
45
+ });
46
+ });
47
+ });
48
+
49
+ describe("sending it", () => {
50
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "val-ci-report-"));
51
+ const report = {
52
+ status: "failed" as const,
53
+ commit: "abc123",
54
+ url: "https://github.com/acme/site/actions/runs/42",
55
+ };
56
+
57
+ test("posts the report to content with the project token", async () => {
58
+ const sent: { url: string; auth: string | null; body: unknown }[] = [];
59
+ const fetchImpl: typeof fetch = async (input, init) => {
60
+ const headers = new Headers(init?.headers);
61
+ sent.push({
62
+ url: String(input),
63
+ auth: headers.get("Authorization"),
64
+ body: JSON.parse(String(init?.body)),
65
+ });
66
+ return new Response(JSON.stringify({ recorded: true }), {
67
+ status: 200,
68
+ headers: { "Content-Type": "application/json" },
69
+ });
70
+ };
71
+ const result = await reportCiRun({
72
+ root,
73
+ report,
74
+ env: {
75
+ VAL_PROJECT_TOKEN: "val_pt_secret",
76
+ VAL_CONTENT_URL: "https://content.test",
77
+ },
78
+ fetchImpl,
79
+ });
80
+ expect(result).toEqual({ status: "reported" });
81
+ expect(sent).toEqual([
82
+ {
83
+ url: "https://content.test/v1/ci-runs",
84
+ auth: "Bearer val_pt_secret",
85
+ body: report,
86
+ },
87
+ ]);
88
+ });
89
+
90
+ test("a refusal is said, not thrown", async () => {
91
+ const fetchImpl: typeof fetch = async () =>
92
+ new Response(JSON.stringify({ message: "Unauthorized" }), {
93
+ status: 401,
94
+ headers: { "Content-Type": "application/json" },
95
+ });
96
+ const result = await reportCiRun({
97
+ root,
98
+ report,
99
+ env: {
100
+ VAL_PROJECT_TOKEN: "val_pt_revoked",
101
+ VAL_CONTENT_URL: "https://content.test",
102
+ },
103
+ fetchImpl,
104
+ });
105
+ expect(result).toMatchObject({
106
+ status: "error",
107
+ message: expect.stringContaining("401"),
108
+ });
109
+ });
110
+ });