@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.3

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.
Files changed (66) hide show
  1. package/akanConfig/akanConfig.test.ts +51 -5
  2. package/akanConfig/akanConfig.ts +31 -12
  3. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  4. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  5. package/applicationBuildRunner.test.ts +33 -1
  6. package/applicationBuildRunner.ts +4 -5
  7. package/cloud/cloudApi.ts +22 -15
  8. package/cloud/cloudHttpError.test.ts +32 -0
  9. package/cloud/cloudHttpError.ts +54 -0
  10. package/cloud/cloudUpdates.ts +138 -0
  11. package/cloud/globalConfig.test.ts +17 -0
  12. package/cloud/globalConfig.ts +8 -2
  13. package/cloud/index.ts +3 -0
  14. package/cloud/pipelineSecrets.ts +114 -0
  15. package/cloud/updatePackUpload.test.ts +100 -45
  16. package/cloud/updatePackUpload.ts +38 -18
  17. package/commandDecorators/argMeta.ts +11 -2
  18. package/commandDecorators/command.ts +40 -6
  19. package/commandDecorators/commandBuilder.ts +6 -1
  20. package/commandDecorators/commandDecorators.test.ts +205 -4
  21. package/commandDecorators/exitCodeError.ts +15 -0
  22. package/commandDecorators/index.ts +1 -0
  23. package/executors.test.ts +69 -0
  24. package/executors.ts +30 -27
  25. package/frontendBuild/csrArtifactBuilder.ts +18 -1
  26. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  29. package/mobile/desktopServerStage.test.ts +13 -0
  30. package/mobile/desktopServerStage.ts +5 -7
  31. package/mobile/index.ts +2 -0
  32. package/mobile/nativeApi.test.ts +26 -0
  33. package/mobile/nativeApi.ts +5 -2
  34. package/mobile/nativeApp.test.ts +135 -33
  35. package/mobile/nativeApp.ts +109 -73
  36. package/mobile/nativeConfig.ts +2 -1
  37. package/mobile/nativePublicEnv.test.ts +131 -0
  38. package/mobile/nativePublicEnv.ts +116 -0
  39. package/mobile/serverBundleSecrets.test.ts +79 -0
  40. package/mobile/serverBundleSecrets.ts +60 -16
  41. package/mobile/updatePackFolder.test.ts +98 -0
  42. package/mobile/updatePackFolder.ts +131 -0
  43. package/mobile/updateUploader.test.ts +114 -22
  44. package/mobile/updateUploader.ts +75 -8
  45. package/package.json +2 -2
  46. package/pipeline/PipelineCompiler.test.ts +157 -1
  47. package/pipeline/PipelineCompiler.ts +80 -46
  48. package/pipeline/PipelineDefinition.test.ts +9 -0
  49. package/pipeline/PipelineDefinition.ts +4 -3
  50. package/pipeline/PipelineDryRun.test.ts +27 -0
  51. package/pipeline/PipelineDryRun.ts +2 -2
  52. package/pipeline/PipelineInputReader.ts +157 -253
  53. package/pipeline/PipelineRules.ts +92 -0
  54. package/pipeline/index.ts +1 -0
  55. package/qualityScanner.test.ts +5 -3
  56. package/slicePlanner.ts +5 -3
  57. package/subspace.test.ts +90 -19
  58. package/subspace.ts +52 -500
  59. package/subspaceClone.ts +361 -0
  60. package/subspaceConfig.ts +1 -2
  61. package/subspaceReport.ts +162 -0
  62. package/transforms/serverEnvPlugin.test.ts +76 -0
  63. package/transforms/serverEnvPlugin.ts +9 -25
  64. package/transforms/serverEnvProfile.test.ts +189 -0
  65. package/transforms/serverEnvProfile.ts +324 -60
  66. package/typecheck/projectTypecheck.ts +6 -5
@@ -16,34 +16,126 @@ const fakeBucket = (held: string[] = []) => {
16
16
  return { bucket, writes };
17
17
  };
18
18
 
19
+ const sha = (char: string) => char.repeat(64);
20
+
21
+ const inUpdatesDir = async (files: Record<string, string>, run: (updatesDir: string) => Promise<void>) => {
22
+ const updatesDir = await mkdtemp(path.join(tmpdir(), "akan-update-upload-"));
23
+ try {
24
+ for (const [file, content] of Object.entries(files)) {
25
+ await mkdir(path.dirname(path.join(updatesDir, file)), { recursive: true });
26
+ await writeFile(path.join(updatesDir, file), content);
27
+ }
28
+ await run(updatesDir);
29
+ } finally {
30
+ await rm(updatesDir, { recursive: true, force: true });
31
+ }
32
+ };
33
+
34
+ const envelope = (manifest: object) =>
35
+ JSON.stringify({
36
+ manifest: Buffer.from(JSON.stringify(manifest)).toString("base64"),
37
+ keyId: "0011223344556677",
38
+ signature: "c2lnbmF0dXJl",
39
+ });
40
+
19
41
  describe("UpdateUploader", () => {
20
42
  test("reads s3://bucket/prefix and refuses anything else", () => {
21
- expect(UpdateUploader.parseTarget("s3://releases/dcms/kiosk/")).toEqual({
43
+ expect(UpdateUploader.parseTarget("s3://releases/acme/kiosk/")).toEqual({
22
44
  bucket: "releases",
23
- prefix: "dcms/kiosk",
45
+ prefix: "acme/kiosk",
24
46
  });
25
47
  expect(UpdateUploader.parseTarget("s3://releases")).toEqual({ bucket: "releases", prefix: "" });
26
- expect(() => UpdateUploader.parseTarget("https://releases.example/dcms")).toThrow("s3://<bucket>/<prefix>");
48
+ expect(() => UpdateUploader.parseTarget("https://releases.example/acme")).toThrow("s3://<bucket>/<prefix>");
27
49
  });
28
50
 
29
- test("uploads what the bucket lacks first, then each signature before its manifest", async () => {
30
- const dir = await mkdtemp(path.join(tmpdir(), "akan-update-upload-"));
31
- try {
32
- await mkdir(path.join(dir, "windows-x64", "files"), { recursive: true });
33
- for (const file of ["windows-x64/files/2000-aa.tar.gz", "windows-x64/files/1000-bb.tar.gz"])
34
- await writeFile(path.join(dir, file), "tar");
35
- for (const file of ["windows-x64/main.json", "windows-x64/main.json.sig", "windows-x64/main.json.tmp"])
36
- await writeFile(path.join(dir, file), "{}");
37
- const { bucket, writes } = fakeBucket(["dcms/windows-x64/files/1000-bb.tar.gz"]);
38
-
39
- expect(await new UpdateUploader(bucket, "dcms").upload(dir)).toEqual({ uploaded: 3, skipped: 1 });
40
- expect(writes).toEqual([
41
- { key: "dcms/windows-x64/files/2000-aa.tar.gz" },
42
- { key: "dcms/windows-x64/main.json.sig", type: "text/plain" },
43
- { key: "dcms/windows-x64/main.json", type: "application/json" },
44
- ]);
45
- } finally {
46
- await rm(dir, { recursive: true, force: true });
47
- }
51
+ test("uploads a desktop release's archives the bucket lacks first, then the signature before the manifest", async () => {
52
+ const manifest = {
53
+ bundle: "1.2.0-2000",
54
+ archive: { url: `app/${sha("b")}.tar.gz` },
55
+ patches: [{ url: `app/${sha("a")}-${sha("b")}.delta.gz` }],
56
+ };
57
+ await inUpdatesDir(
58
+ {
59
+ [`windows-x64/app/${sha("a")}.tar.gz`]: "previous release",
60
+ [`windows-x64/app/${sha("b")}.tar.gz`]: "tar",
61
+ [`windows-x64/app/${sha("a")}-${sha("b")}.delta.gz`]: "delta",
62
+ "windows-x64/main.json": JSON.stringify(manifest),
63
+ "windows-x64/main.json.sig": "sig",
64
+ "windows-x64/main.json.tmp": "{}",
65
+ "windows-x64/develop.json": JSON.stringify({ ...manifest, bundle: "1.1.0-1000" }),
66
+ "windows-x64/develop.json.sig": "sig",
67
+ },
68
+ async (updatesDir) => {
69
+ const { bucket, writes } = fakeBucket([`acme/windows-x64/app/${sha("a")}-${sha("b")}.delta.gz`]);
70
+ const release = { dir: path.join(updatesDir, "windows-x64"), channel: "main", bundle: "1.2.0-2000" };
71
+
72
+ expect(await new UpdateUploader(bucket, "acme").upload(updatesDir, release)).toEqual({
73
+ uploaded: 3,
74
+ skipped: 1,
75
+ });
76
+ expect(writes).toEqual([
77
+ { key: `acme/windows-x64/app/${sha("b")}.tar.gz` },
78
+ { key: "acme/windows-x64/main.json.sig", type: "text/plain" },
79
+ { key: "acme/windows-x64/main.json", type: "application/json" },
80
+ ]);
81
+ },
82
+ );
83
+ });
84
+
85
+ test("uploads a phone release's own files and envelope, never pack records or another manifest beside them", async () => {
86
+ await inUpdatesDir(
87
+ {
88
+ "android/pack.json": JSON.stringify({ out: "/Users/someone/work/dist/native/demo/default/updates/android" }),
89
+ "android/manifest.template.json": "{}",
90
+ "android/bundle.json": "{}",
91
+ "android/compat.json": "{}",
92
+ [`android/files/${sha("a")}`]: "index.html",
93
+ [`android/files/${sha("b")}`]: "env.runtime.json",
94
+ [`android/files/${sha("c")}`]: "an earlier pack's file",
95
+ "android/main/api2.json": envelope({
96
+ bundle: "3000-aaaaaaaa",
97
+ files: [{ sha256: sha("a") }, { sha256: sha("b") }, { sha256: sha("a") }],
98
+ }),
99
+ "android/main/api1.json": envelope({ bundle: "1000-cccccccc", files: [{ sha256: sha("c") }] }),
100
+ "android/develop/api2.json": envelope({ bundle: "2000-bbbbbbbb", files: [{ sha256: sha("b") }] }),
101
+ },
102
+ async (updatesDir) => {
103
+ const { bucket, writes } = fakeBucket();
104
+ const release = { dir: path.join(updatesDir, "android"), channel: "main", bundle: "3000-aaaaaaaa" };
105
+
106
+ expect(await new UpdateUploader(bucket, "").upload(updatesDir, release)).toEqual({ uploaded: 3, skipped: 0 });
107
+ expect(writes).toEqual([
108
+ { key: `android/files/${sha("a")}` },
109
+ { key: `android/files/${sha("b")}` },
110
+ { key: "android/main/api2.json", type: "application/json" },
111
+ ]);
112
+ },
113
+ );
114
+ });
115
+
116
+ test("uploads nothing when the release is not there or names a file outside its folder", async () => {
117
+ await inUpdatesDir(
118
+ {
119
+ "linux-x64/main.json": JSON.stringify({ bundle: "1.0.0-10", archive: { url: "../../.ssh/id_ed25519" } }),
120
+ "linux-x64/main.json.sig": "sig",
121
+ "ios/main/api1.json": envelope({ bundle: "10-aaaaaaaa", files: [{ sha256: "../pack.json" }] }),
122
+ },
123
+ async (updatesDir) => {
124
+ const { bucket, writes } = fakeBucket();
125
+ const uploader = new UpdateUploader(bucket, "acme");
126
+ const linux = path.join(updatesDir, "linux-x64");
127
+
128
+ await expect(uploader.upload(updatesDir, { dir: linux, channel: "main", bundle: "1.0.0-9" })).rejects.toThrow(
129
+ "holds no main manifest of release 1.0.0-9",
130
+ );
131
+ await expect(uploader.upload(updatesDir, { dir: linux, channel: "main", bundle: "1.0.0-10" })).rejects.toThrow(
132
+ "which is not inside that folder",
133
+ );
134
+ await expect(
135
+ uploader.upload(updatesDir, { dir: path.join(updatesDir, "ios"), channel: "main", bundle: "10-aaaaaaaa" }),
136
+ ).rejects.toThrow("not 64 hex digits");
137
+ expect(writes).toEqual([]);
138
+ },
139
+ );
48
140
  });
49
141
  });
@@ -1,4 +1,8 @@
1
+ import { readdir } from "node:fs/promises";
1
2
  import path from "node:path";
3
+ import type { UpdateEnvelope, UpdateManifest } from "@akanjs/native/api";
4
+ import { isRecord } from "akanjs/common";
5
+ import { UpdatePackFolder } from "./updatePackFolder";
2
6
 
3
7
  /** What the uploader needs of an S3 client; Bun.S3Client is one. */
4
8
  export interface UpdateBucket {
@@ -11,6 +15,15 @@ export interface UpdateUploadResult {
11
15
  skipped: number;
12
16
  }
13
17
 
18
+ /** The release one `publish-update` wrote, as it reports it. */
19
+ export interface PublishedUpdate {
20
+ /** `<updates>/<os>-<arch>` for a desktop app, `<updates>/<platform>` for a phone's web bundle. */
21
+ dir: string;
22
+ channel: string;
23
+ /** Its manifest's `bundle`, which tells it from the other manifests of the channel there. */
24
+ bundle: string;
25
+ }
26
+
14
27
  //* A manifest names files that must already be there, so they go first and the manifests last, each signature before
15
28
  //* its manifest as `publish-update` writes them. A release file never changes once published (its name carries its
16
29
  //* hash), so one the bucket holds is skipped; a manifest is always written.
@@ -34,6 +47,64 @@ export class UpdateUploader {
34
47
  };
35
48
  }
36
49
 
50
+ //* Never the whole folder: pack-update's records sit there too, and so do manifests of other channels and native
51
+ //* APIs, which a later publish from another machine may have replaced.
52
+ static async releaseFiles(updatesDir: string, { dir, channel, bundle }: PublishedUpdate): Promise<string[]> {
53
+ const keyOf = (file: unknown) => UpdateUploader.#keyOf(updatesDir, dir, file);
54
+ const desktop = await UpdateUploader.#read<UpdateManifest>(path.join(dir, `${channel}.json`));
55
+ if (desktop?.bundle === bundle) {
56
+ const archive = desktop.archive?.url;
57
+ const patches = UpdateUploader.#field(desktop.patches, "url");
58
+ return [archive, ...patches, `${channel}.json.sig`, `${channel}.json`].map(keyOf);
59
+ }
60
+ const names = await readdir(path.join(dir, channel)).catch(() => [] as string[]);
61
+ for (const name of names.filter((name) => name.endsWith(".json"))) {
62
+ const envelope = await UpdateUploader.#read<UpdateEnvelope>(path.join(dir, channel, name));
63
+ const manifest =
64
+ typeof envelope?.manifest === "string"
65
+ ? UpdateUploader.#parse<UpdateManifest>(Buffer.from(envelope.manifest, "base64").toString("utf8"))
66
+ : null;
67
+ if (manifest?.bundle !== bundle) continue;
68
+ const hashes = [...new Set(UpdateUploader.#field(manifest.files, "sha256"))];
69
+ if (!hashes.every((hash): hash is string => typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)))
70
+ throw new Error(
71
+ `${path.join(dir, channel, name)} lists a file whose sha256 is not 64 hex digits; nothing was uploaded.`,
72
+ );
73
+ return [...hashes.map((hash) => `${UpdatePackFolder.filesDir}/${hash}`), `${channel}/${name}`].map(keyOf);
74
+ }
75
+ throw new Error(`${dir} holds no ${channel} manifest of release ${bundle}; nothing was uploaded.`);
76
+ }
77
+
78
+ static #keyOf(updatesDir: string, dir: string, file: unknown) {
79
+ const resolved = typeof file === "string" ? path.resolve(dir, file) : "";
80
+ const key = path.relative(updatesDir, resolved);
81
+ if (!resolved.startsWith(`${path.resolve(dir)}${path.sep}`) || key.startsWith("..") || path.isAbsolute(key))
82
+ throw new Error(
83
+ `The release in ${dir} names ${typeof file === "string" ? JSON.stringify(file) : "a file with no path"}, which is not inside that folder; nothing was uploaded.`,
84
+ );
85
+ return key.split(path.sep).join("/");
86
+ }
87
+
88
+ static #field(list: unknown, key: string): unknown[] {
89
+ return Array.isArray(list) ? list.map((item) => (isRecord(item) ? item[key] : undefined)) : [];
90
+ }
91
+
92
+ static async #read<T>(file: string) {
93
+ const text = await Bun.file(file)
94
+ .text()
95
+ .catch(() => "");
96
+ return UpdateUploader.#parse<T>(text);
97
+ }
98
+
99
+ static #parse<T>(text: string): Partial<T> | null {
100
+ try {
101
+ const value: unknown = JSON.parse(text);
102
+ return isRecord(value) ? (value as Partial<T>) : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
37
108
  constructor(
38
109
  readonly bucket: UpdateBucket,
39
110
  readonly prefix: string,
@@ -44,12 +115,8 @@ export class UpdateUploader {
44
115
  return new UpdateUploader(new Bun.S3Client({ bucket }), prefix);
45
116
  }
46
117
 
47
- async upload(dir: string): Promise<UpdateUploadResult> {
48
- const files: string[] = [];
49
- for await (const file of new Bun.Glob("**/*").scan({ cwd: dir, onlyFiles: true })) {
50
- if (!file.endsWith(".tmp")) files.push(file.split(path.sep).join("/"));
51
- }
52
- const { releases, manifests } = UpdateUploader.order(files);
118
+ async upload(updatesDir: string, release: PublishedUpdate): Promise<UpdateUploadResult> {
119
+ const { releases, manifests } = UpdateUploader.order(await UpdateUploader.releaseFiles(updatesDir, release));
53
120
  const keyOf = (file: string) => (this.prefix ? `${this.prefix}/${file}` : file);
54
121
  let uploaded = 0;
55
122
  let skipped = 0;
@@ -58,12 +125,12 @@ export class UpdateUploader {
58
125
  skipped++;
59
126
  continue;
60
127
  }
61
- await this.bucket.write(keyOf(file), Bun.file(path.join(dir, file)));
128
+ await this.bucket.write(keyOf(file), Bun.file(path.join(updatesDir, file)));
62
129
  uploaded++;
63
130
  }
64
131
  for (const file of manifests) {
65
132
  const type = file.endsWith(".json") ? "application/json" : "text/plain";
66
- await this.bucket.write(keyOf(file), Bun.file(path.join(dir, file)), { type });
133
+ await this.bucket.write(keyOf(file), Bun.file(path.join(updatesDir, file)), { type });
67
134
  uploaded++;
68
135
  }
69
136
  return { uploaded, skipped };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.0-rc.1",
3
+ "version": "3.1.0-rc.3",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@earendil-works/pi-tui": "0.86.1",
49
49
  "@inquirer/prompts": "^8.4.3",
50
50
  "@tailwindcss/node": "^4.3.0",
51
- "akanjs": "3.1.0-rc.1",
51
+ "akanjs": "3.1.0-rc.3",
52
52
  "chalk": "^5.6.2",
53
53
  "commander": "^14.0.3",
54
54
  "dayjs": "^1.11.20",
@@ -337,7 +337,28 @@ describe("PipelineCompiler", () => {
337
337
  ]);
338
338
  const e2e = plan.phases.at(-1);
339
339
  expect(e2e).toMatchObject({ id: "e2e", timeoutSeconds: 3600, milestone: "finish" });
340
- expect(e2e?.stages.map((stage) => [stage.id, stage.name, stage.app])).toEqual([["e2e:api", "api", "api"]]);
340
+ expect(e2e?.stages.map((stage) => [stage.id, stage.name, stage.app])).toEqual([["e2e.apps.api", "api", "api"]]);
341
+ });
342
+
343
+ test("a matrix copy's id never collides with an app's own stage or a post step", () => {
344
+ const stageIds = (plan: PipelinePlan) => plan.phases.flatMap((phase) => phase.stages.map((stage) => stage.id));
345
+ const plan = planOf(
346
+ compile({
347
+ workspace: { after: [{ name: "Web", matrix: "apps", sh: "bun run e2e $AKAN_CI_APP" }] },
348
+ apps: { web: { stages: [{ use: "build" }, { name: "API", sh: "bun run test:api" }] } },
349
+ }),
350
+ );
351
+ expect(stageIds(plan)).toEqual(expect.arrayContaining(["web:api", "web.apps.api", "web.apps.web"]));
352
+ expect(new Set(stageIds(plan)).size).toBe(stageIds(plan).length);
353
+ const post = planOf(
354
+ compile({
355
+ ctx: context({ apps: ["success"] }),
356
+ workspaceApps: ["success"],
357
+ workspace: { after: [{ name: "Post", matrix: "apps", sh: "x" }], post: { success: ["y"] } },
358
+ }),
359
+ );
360
+ expect(stageIds(post)).toContain("post.apps.success");
361
+ expect(post.post.success.map((step) => step.id)).toEqual(["post.success/1"]);
341
362
  });
342
363
 
343
364
  test("env is workspace, then app, then stage, then step; secrets are each step's union", () => {
@@ -571,6 +592,10 @@ describe("PipelineCompiler", () => {
571
592
  expect(codes(appResult({ stages: [{ name: "x", sh: "x", secrets: ["lower"] }] }))).toContain(
572
593
  "pipeline.secret.name",
573
594
  );
595
+ const pasted = appResult({ stages: [{ name: "x", sh: "x", secrets: ["NPM_TOKEN", "npm_live_value"] }] });
596
+ const secretName = pasted.diagnostics.find((diagnostic) => diagnostic.code === "pipeline.secret.name");
597
+ expect(secretName?.path).toEndWith(".secrets[1]");
598
+ expect(secretName?.message).not.toContain("npm_live_value");
574
599
  expect(codes(appResult({ stages: [{ name: "x", steps: [{ sh: "x", cwd: "../up" }] }] }))).toContain(
575
600
  "pipeline.cwd.outside",
576
601
  );
@@ -604,6 +629,30 @@ describe("PipelineCompiler", () => {
604
629
  );
605
630
  });
606
631
 
632
+ test("a parallel branch may not take the id of a post list's steps", () => {
633
+ const refused = compile({
634
+ workspace: { before: [{ name: "Post", parallel: [{ name: "Success", sh: "x" }] }], post: { success: ["y"] } },
635
+ });
636
+ expect(refused.ok).toBe(false);
637
+ expect(refused.diagnostics).toEqual([
638
+ expect.objectContaining({
639
+ code: "pipeline.id.reserved",
640
+ file: "akan.config.ts",
641
+ path: "pipeline.before[0].parallel[0].name",
642
+ }),
643
+ ]);
644
+ expect(
645
+ compile({
646
+ workspace: { after: [{ id: "post", name: "Checks", parallel: [{ id: "always", name: "A", sh: "x" }] }] },
647
+ }).diagnostics,
648
+ ).toEqual([expect.objectContaining({ code: "pipeline.id.reserved", path: "pipeline.after[0].parallel[0].id" })]);
649
+ const otherBranch = compile({
650
+ workspace: { before: [{ name: "Post", parallel: [{ name: "Lint", sh: "x" }] }], post: { success: ["y"] } },
651
+ });
652
+ expect(otherBranch.ok).toBe(true);
653
+ expect(compile({ workspace: { after: [{ name: "Post", sh: "x" }], post: { success: ["y"] } } }).ok).toBe(true);
654
+ });
655
+
607
656
  test("loading and sampling", () => {
608
657
  const thrown = appResult(() => {
609
658
  throw new Error("boom");
@@ -620,6 +669,113 @@ describe("PipelineCompiler", () => {
620
669
  expect(codes(sampled)).toContain("pipeline.function.sampled");
621
670
  });
622
671
 
672
+ test("a function returning nothing, an async one, and a falsy pipeline are errors, never a default", async () => {
673
+ const refused = (result: PipelinePlanResult, file: string) => {
674
+ expect(result.ok).toBe(false);
675
+ expect(result.diagnostics).toContainEqual(
676
+ expect.objectContaining({ severity: "error", code: "pipeline.schema.invalid", file, path: "pipeline" }),
677
+ );
678
+ return result.diagnostics.map((diagnostic) => diagnostic.message).join("\n");
679
+ };
680
+ const onMainOnly = ((ctx: { branch: string }) =>
681
+ ctx.branch === "main" ? { stages: [{ use: "build" }] } : undefined) as unknown as AppPipelineConfig;
682
+ expect(appResult(onMainOnly).ok).toBe(true);
683
+ const develop = compile({ ctx: context({ branch: "develop", apps: ["web"] }), apps: { web: onMainOnly } });
684
+ expect(refused(develop, "apps/web/akan.config.ts")).toContain("returned nothing");
685
+ expect(refused(compile({ workspace: (() => undefined) as never }), "akan.config.ts")).toContain(
686
+ "returned nothing",
687
+ );
688
+ expect(refused(compile({ workspace: (async () => ({ before: [] })) as never }), "akan.config.ts")).toContain(
689
+ "Promise",
690
+ );
691
+ const rejecting = (async () => {
692
+ throw new Error("boom");
693
+ }) as unknown as AppPipelineConfig;
694
+ expect(refused(appResult(rejecting), "apps/web/akan.config.ts")).toContain("Promise");
695
+ await Bun.sleep(1);
696
+ expect(refused(appResult(false as never), "apps/web/akan.config.ts")).toContain("must be an object");
697
+ expect(refused(compile({ workspace: 0 as never }), "akan.config.ts")).toContain("must be an object");
698
+ });
699
+
700
+ test("a plan larger than an executor reads, as written, is refused", () => {
701
+ const steps = Array.from({ length: 20 }, () => "x".repeat(60_000));
702
+ const result = appResult({ stages: [{ use: "build" }, { name: "Big", steps }] });
703
+ expect(result.ok).toBe(false);
704
+ expect(result.diagnostics).toContainEqual(
705
+ expect.objectContaining({ code: "pipeline.limit.exceeded", file: null, path: null }),
706
+ );
707
+ });
708
+
709
+ test("conditions", () => {
710
+ expect(appResult({ stages: [{ use: "build", when: { trigger: [] } }] }).diagnostics).toContainEqual(
711
+ expect.objectContaining({ code: "pipeline.schema.invalid", path: "pipeline.stages[0].when.trigger" }),
712
+ );
713
+ const branch = Array.from({ length: 6 }, (_, idx) => `release/${String(idx).repeat(240)}`);
714
+ const plan = planOf(
715
+ compile({
716
+ ctx: context({ branch: "develop", env: "develop", apps: ["web"] }),
717
+ apps: { web: { stages: [{ use: "build", when: { branch } }] } },
718
+ }),
719
+ );
720
+ const reason = plan.phases[0]?.stages[0]?.skip?.reason ?? "";
721
+ expect(reason.startsWith('branch "develop" does not match "release/')).toBe(true);
722
+ expect(reason.endsWith("…")).toBe(true);
723
+ expect(reason.length).toBeLessThanOrEqual(1024);
724
+ });
725
+
726
+ test("a value the plan would refuse is reported where it was written", () => {
727
+ expect(
728
+ appResult({ stages: [{ use: "build" }, { name: "x", sh: "x", env: { BIG: "x".repeat(64 * 1024 + 1) } }] })
729
+ .diagnostics,
730
+ ).toContainEqual(
731
+ expect.objectContaining({ code: "pipeline.limit.exceeded", path: "pipeline.stages[1].env.BIG" }),
732
+ );
733
+ const unset = compile({ workspace: { before: [{ name: "Checks", parallel: undefined as never }] } });
734
+ expect(unset.ok).toBe(false);
735
+ expect(unset.diagnostics).toEqual([
736
+ expect.objectContaining({
737
+ code: "pipeline.schema.invalid",
738
+ file: "akan.config.ts",
739
+ path: "pipeline.before[0].parallel",
740
+ }),
741
+ ]);
742
+ });
743
+
744
+ test("a key set to undefined is the key left out", () => {
745
+ const stage = { name: "Test", sh: "bun test", use: undefined, parallel: undefined, matrix: undefined };
746
+ const plan = planOf(
747
+ compile({
748
+ workspace: {
749
+ before: [stage as never, { name: "Checks", parallel: [{ name: "Lint", sh: "x", use: undefined }] }],
750
+ },
751
+ apps: { web: { stages: [{ use: "build", targets: undefined } as never, stage as never] } },
752
+ }),
753
+ );
754
+ expect(phaseIds(plan)).toEqual(["test", "checks", "build", "after-build", "docker", "deploy"]);
755
+ expect(appResult({ stages: [{ use: undefined } as never] }).diagnostics).toContainEqual(
756
+ expect.objectContaining({ code: "pipeline.schema.invalid", path: "pipeline.stages[0].use" }),
757
+ );
758
+ });
759
+
760
+ test("a value JSON cannot hold is reported, never thrown", () => {
761
+ const cyclic: Record<string, unknown> = {};
762
+ cyclic.self = cyclic;
763
+ const result = appResult({ stages: [{ use: "build", timeout: 10n as never }, { use: cyclic as never }] });
764
+ expect(result.ok).toBe(false);
765
+ expect(result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.message.split(" ")[0]])).toEqual([
766
+ ["pipeline.duration.invalid", "10n"],
767
+ ["pipeline.schema.invalid", "[object"],
768
+ ]);
769
+ });
770
+
771
+ test("a long first line names its step without splitting a surrogate pair", () => {
772
+ const plan = planOf(
773
+ appResult({ stages: [{ use: "build" }, { name: "Emoji", sh: `${"a".repeat(78)}${"😀".repeat(5)}` }] }),
774
+ );
775
+ const name = plan.phases.find((phase) => phase.id === "after-build")?.stages[0]?.steps[0]?.name;
776
+ expect(name).toBe(`${"a".repeat(78)}…`);
777
+ });
778
+
623
779
  test("milestones that move backwards are a warning", () => {
624
780
  const result = appResult({
625
781
  stages: [{ use: "build" }, { use: "docker" }, { name: "Late", sh: "x", milestone: "verify" }],