@akanjs/devkit 3.0.2-rc.0 → 3.1.0-rc.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +46 -7
  2. package/akanApp/akanApp.host.test.ts +62 -0
  3. package/akanApp/akanApp.host.ts +16 -4
  4. package/akanApp/devHostPolicy.test.ts +14 -0
  5. package/akanApp/devHostPolicy.ts +11 -0
  6. package/akanConfig/akanConfig.test.ts +77 -7
  7. package/akanConfig/akanConfig.ts +111 -13
  8. package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
  9. package/akanConfig/akanWorkspaceConfig.ts +63 -0
  10. package/akanConfig/index.ts +1 -0
  11. package/akanConfig/types.ts +23 -1
  12. package/akanContext.ts +1 -1
  13. package/applicationBuildRunner.test.ts +1 -1
  14. package/applicationBuildRunner.ts +73 -84
  15. package/artifact/implicitRootLayout.test.ts +6 -2
  16. package/artifact/implicitRootLayout.ts +12 -7
  17. package/biome.base.json +7 -2
  18. package/cloud/cloudApi.ts +82 -0
  19. package/cloud/globalConfig.ts +4 -0
  20. package/cloud/index.ts +1 -0
  21. package/cloud/updatePackUpload.test.ts +195 -0
  22. package/cloud/updatePackUpload.ts +115 -0
  23. package/commandDecorators/command.ts +8 -2
  24. package/commandDecorators/targetMeta.ts +1 -0
  25. package/createTunnel.test.ts +51 -0
  26. package/createTunnel.ts +32 -14
  27. package/executors.test.ts +21 -0
  28. package/executors.ts +9 -2
  29. package/frontendBuild/csrArtifactBuilder.ts +10 -1
  30. package/frontendBuild/cssCandidateCache.test.ts +10 -0
  31. package/frontendBuild/cssCandidateCache.ts +4 -2
  32. package/frontendBuild/pagesBundleBuilder.ts +5 -1
  33. package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
  34. package/incrementalBuilder/buildBatch.proc.ts +14 -2
  35. package/incrementalBuilder/buildBatchProtocol.ts +3 -2
  36. package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
  37. package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
  38. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
  39. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
  40. package/lint/no-import-external-library-in-test.grit +24 -0
  41. package/mobile/desktopServerStage.test.ts +6 -0
  42. package/mobile/desktopServerStage.ts +25 -3
  43. package/mobile/index.ts +1 -0
  44. package/mobile/nativeApp.test.ts +1 -0
  45. package/mobile/nativeApp.ts +96 -34
  46. package/mobile/nativeConfig.test.ts +59 -1
  47. package/mobile/nativeConfig.ts +13 -3
  48. package/mobile/serverBundleSecrets.test.ts +61 -0
  49. package/mobile/serverBundleSecrets.ts +85 -0
  50. package/mobile/updateUploader.test.ts +49 -0
  51. package/mobile/updateUploader.ts +71 -0
  52. package/package.json +3 -2
  53. package/pipeline/PipelineCompiler.test.ts +631 -0
  54. package/pipeline/PipelineCompiler.ts +646 -0
  55. package/pipeline/PipelineDefinition.test.ts +89 -0
  56. package/pipeline/PipelineDefinition.ts +117 -0
  57. package/pipeline/PipelineDryRun.test.ts +93 -0
  58. package/pipeline/PipelineDryRun.ts +89 -0
  59. package/pipeline/PipelineInputReader.ts +739 -0
  60. package/pipeline/index.ts +4 -0
  61. package/qualityScanner.test.ts +9 -3
  62. package/qualityScanner.ts +5 -2
  63. package/scanInfo.ts +13 -6
  64. package/subspace.test.ts +108 -4
  65. package/subspace.ts +162 -10
  66. package/subspaceConfig.ts +11 -14
  67. package/testHelpers.ts +4 -0
  68. package/transforms/externalizeFrameworkPlugin.ts +1 -7
  69. package/transforms/serverEnvPlugin.test.ts +93 -2
  70. package/transforms/serverEnvPlugin.ts +71 -18
  71. package/transforms/serverEnvProfile.test.ts +82 -0
  72. package/transforms/serverEnvProfile.ts +114 -0
  73. package/typecheck/projectTypecheck.test.ts +32 -0
  74. package/typecheck/projectTypecheck.ts +71 -0
  75. package/workspaceLayout.test.ts +80 -3
  76. package/workspaceLayout.ts +15 -4
@@ -0,0 +1,631 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AppPipelineConfig, WorkspacePipelineConfig } from "akanjs";
3
+ import type { PipelinePlan, PipelinePlanContext, PipelinePlanResult } from "akanjs/common";
4
+ import {
5
+ type PipelineCompileInput,
6
+ PipelineCompiler,
7
+ type PipelineNativeTargets,
8
+ type PipelineSourceFile,
9
+ } from "./PipelineCompiler";
10
+
11
+ const sha = "0".repeat(64);
12
+ const context = (overrides: Partial<PipelinePlanContext> = {}): PipelinePlanContext => ({
13
+ branch: "main",
14
+ env: "main",
15
+ apps: ["api", "web"],
16
+ trigger: "push",
17
+ buildNum: 12,
18
+ commit: "5ab138a1",
19
+ ...overrides,
20
+ });
21
+
22
+ const appSource = (app: string, pipeline: AppPipelineConfig | null = null): PipelineSourceFile<AppPipelineConfig> => ({
23
+ file: `apps/${app}/akan.config.ts`,
24
+ sha256: sha,
25
+ pipeline,
26
+ });
27
+
28
+ const compile = ({
29
+ apps = {},
30
+ workspace,
31
+ ctx = context(),
32
+ workspaceApps = ["api", "edge", "web"],
33
+ sampled,
34
+ nativeTargets,
35
+ }: {
36
+ apps?: Record<string, AppPipelineConfig | null>;
37
+ workspace?: WorkspacePipelineConfig | null;
38
+ ctx?: PipelinePlanContext;
39
+ workspaceApps?: string[];
40
+ sampled?: boolean;
41
+ nativeTargets?: Record<string, PipelineNativeTargets>;
42
+ } = {}): PipelinePlanResult => {
43
+ const input: PipelineCompileInput = {
44
+ context: ctx,
45
+ workspaceApps,
46
+ workspace: workspace === undefined ? null : { file: "akan.config.ts", sha256: sha, pipeline: workspace },
47
+ apps: new Map(ctx.apps.map((app) => [app, appSource(app, apps[app] ?? null)])),
48
+ akanVersion: "3.0.3",
49
+ sampled,
50
+ nativeTargets: nativeTargets && new Map(Object.entries(nativeTargets)),
51
+ };
52
+ return PipelineCompiler.compile(input);
53
+ };
54
+
55
+ const planOf = (result: PipelinePlanResult): PipelinePlan => {
56
+ if (!result.ok) throw new Error(JSON.stringify(result.diagnostics, null, 2));
57
+ return result.plan;
58
+ };
59
+
60
+ const codes = (result: PipelinePlanResult) => result.diagnostics.map((diagnostic) => diagnostic.code);
61
+
62
+ const phaseIds = (plan: PipelinePlan) => plan.phases.map((phase) => phase.id);
63
+
64
+ describe("PipelineCompiler", () => {
65
+ test("zero-config: build, docker and deploy, deploy one app at a time in the given order", () => {
66
+ const plan = planOf(compile());
67
+ const actionStage = (app: string, action: "build" | "docker" | "deploy", milestone: string, step: string) => ({
68
+ id: `${app}:${action}`,
69
+ name: app,
70
+ description: null,
71
+ app,
72
+ milestone,
73
+ skip: null,
74
+ runtimeWhen: null,
75
+ allowFailure: false,
76
+ timeoutSeconds: null,
77
+ steps: [
78
+ {
79
+ id: `${app}:${action}/${action}`,
80
+ name: `${step} ${app}`,
81
+ timeoutSeconds: null,
82
+ retry: null,
83
+ allowFailure: false,
84
+ kind: "action",
85
+ action,
86
+ actionVersion: 1,
87
+ with: { app },
88
+ },
89
+ ],
90
+ });
91
+ const actionPhase = (action: "build" | "docker" | "deploy", name: string, milestone: string, step: string) => ({
92
+ id: action,
93
+ name,
94
+ description: null,
95
+ slot: action,
96
+ milestone,
97
+ action,
98
+ order: action === "deploy" ? "sequential" : "parallel",
99
+ maxParallel: null,
100
+ failFast: false,
101
+ timeoutSeconds: null,
102
+ stages: ["api", "web"].map((app) => actionStage(app, action, milestone, step)),
103
+ });
104
+ expect(plan).toEqual({
105
+ schemaVersion: 1,
106
+ sources: {
107
+ workspace: null,
108
+ apps: {
109
+ api: { file: "apps/api/akan.config.ts", sha256: sha, declared: false },
110
+ web: { file: "apps/web/akan.config.ts", sha256: sha, declared: false },
111
+ },
112
+ },
113
+ planner: { akanVersion: "3.0.3" },
114
+ context: context(),
115
+ appOrder: ["api", "web"],
116
+ timeoutSeconds: null,
117
+ requiredSecrets: [],
118
+ phases: [
119
+ actionPhase("build", "Build", "build", "akan build"),
120
+ actionPhase("docker", "Docker", "package", "image"),
121
+ actionPhase("deploy", "Deploy", "deploy", "deploy"),
122
+ ],
123
+ post: { success: [], failure: [], canceled: [], always: [] },
124
+ } as PipelinePlan);
125
+ });
126
+
127
+ test("a root config holding no pipeline still plans as zero-config, and says the file was read", () => {
128
+ const plan = planOf(compile({ workspace: null }));
129
+ expect(phaseIds(plan)).toEqual(["build", "docker", "deploy"]);
130
+ expect(plan.sources.workspace).toEqual({ file: "akan.config.ts", sha256: sha, declared: false });
131
+ });
132
+
133
+ test("the akanjs repo's Jenkinsfile, split into a root config and the app's own stages", () => {
134
+ const notify = (result: string) => ({ name: `Discord: ${result}`, sh: "curl …", secrets: ["DISCORD_WEBHOOK"] });
135
+ const plan = planOf(
136
+ compile({
137
+ ctx: context({ apps: ["akan"] }),
138
+ workspaceApps: ["akan"],
139
+ workspace: {
140
+ before: [
141
+ { name: "Notify start", steps: [notify("Build Start")] },
142
+ { name: "Build framework", sh: "bun run buildAkan" },
143
+ { name: "Typecheck", sh: "bun run typecheckPkgs" },
144
+ { name: "Test", sh: "bun run testPkgs" },
145
+ ],
146
+ after: [
147
+ { name: "Dev stability", sh: "bun run testDevStability", timeout: "45m", allowFailure: true },
148
+ {
149
+ name: "Dev registry E2E",
150
+ timeout: "20m",
151
+ allowFailure: true,
152
+ steps: [{ sh: "bun run testDevRegistryE2e", skipExitCodes: [3] }],
153
+ },
154
+ ],
155
+ post: { success: [notify("Build Succeed")], failure: [notify("Build Failed")] },
156
+ },
157
+ apps: {
158
+ akan: {
159
+ stages: [
160
+ { name: "Docs search", sh: "bun run runAkan script akan generateDocsSearch" },
161
+ { use: "build" },
162
+ { use: "docker" },
163
+ { use: "deploy" },
164
+ ],
165
+ },
166
+ },
167
+ }),
168
+ );
169
+ expect(phaseIds(plan)).toEqual([
170
+ "notify-start",
171
+ "build-framework",
172
+ "typecheck",
173
+ "test",
174
+ "before-build",
175
+ "build",
176
+ "docker",
177
+ "deploy",
178
+ "dev-stability",
179
+ "dev-registry-e2e",
180
+ ]);
181
+ expect(plan.phases.map((phase) => phase.milestone)).toEqual([
182
+ "prepare",
183
+ "prepare",
184
+ "prepare",
185
+ "prepare",
186
+ "prepare",
187
+ "build",
188
+ "package",
189
+ "deploy",
190
+ "finish",
191
+ "finish",
192
+ ]);
193
+ const e2e = plan.phases.at(-1)?.stages[0];
194
+ expect(e2e).toMatchObject({ id: "dev-registry-e2e", allowFailure: true, timeoutSeconds: 1200 });
195
+ expect(e2e?.steps[0]).toMatchObject({
196
+ id: "dev-registry-e2e/1",
197
+ skipExitCodes: [3],
198
+ name: "bun run testDevRegistryE2e",
199
+ });
200
+ expect(plan.post.success.map((step) => step.id)).toEqual(["post.success/discord-build-succeed"]);
201
+ expect(plan.requiredSecrets).toEqual(["DISCORD_WEBHOOK"]);
202
+ expect(plan.sources.apps).toMatchObject({ akan: { declared: true } });
203
+ expect(plan.sources.workspace?.declared).toBe(true);
204
+ });
205
+
206
+ test("app stages fill the slots around the actions, the k-th of every app sharing one phase", () => {
207
+ const plan = planOf(
208
+ compile({
209
+ apps: {
210
+ web: {
211
+ stages: [
212
+ { use: "build" },
213
+ { name: "Test", sh: "bun test" },
214
+ { name: "Lint", sh: "bun lint" },
215
+ { use: "docker" },
216
+ { use: "deploy" },
217
+ { name: "Smoke", sh: "curl -fsS https://web.example.com/api/ping" },
218
+ ],
219
+ },
220
+ api: { stages: [{ use: "build" }, { name: "Typecheck", sh: "tsc" }, { use: "docker" }, { use: "deploy" }] },
221
+ },
222
+ }),
223
+ );
224
+ expect(phaseIds(plan)).toEqual(["build", "after-build", "after-build-2", "docker", "deploy", "after-deploy"]);
225
+ const stagesOf = (id: string) => plan.phases.find((phase) => phase.id === id)?.stages.map((stage) => stage.id);
226
+ expect(stagesOf("after-build")).toEqual(["api:typecheck", "web:test"]);
227
+ expect(stagesOf("after-build-2")).toEqual(["web:lint"]);
228
+ expect(stagesOf("after-deploy")).toEqual(["web:smoke"]);
229
+ expect(plan.phases.find((phase) => phase.id === "after-build-2")).toMatchObject({
230
+ name: "After build 2",
231
+ milestone: "verify",
232
+ });
233
+ expect(plan.phases.find((phase) => phase.id === "after-deploy")?.stages[0]?.milestone).toBe("deploy");
234
+ });
235
+
236
+ test("`after` orders the apps where they run one at a time, ties keeping the given order", () => {
237
+ const plan = planOf(
238
+ compile({
239
+ ctx: context({ apps: ["web", "edge", "api"] }),
240
+ apps: { web: { stages: [{ use: "build" }, { use: "docker" }, { use: "deploy" }], after: ["api", "absent"] } },
241
+ }),
242
+ );
243
+ expect(plan.appOrder).toEqual(["edge", "api", "web"]);
244
+ expect(plan.context.apps).toEqual(["web", "edge", "api"]);
245
+ expect(plan.phases.find((phase) => phase.id === "deploy")?.stages.map((stage) => stage.app)).toEqual([
246
+ "edge",
247
+ "api",
248
+ "web",
249
+ ]);
250
+ });
251
+
252
+ test("an `after` cycle is an error naming the apps in it", () => {
253
+ const result = compile({
254
+ apps: {
255
+ api: { stages: [{ use: "build" }], after: ["web"] },
256
+ web: { stages: [{ use: "build" }], after: ["api"] },
257
+ },
258
+ });
259
+ expect(result.ok).toBe(false);
260
+ expect(result.diagnostics.find((diagnostic) => diagnostic.code === "pipeline.app.afterCycle")?.message).toContain(
261
+ "api, web",
262
+ );
263
+ });
264
+
265
+ test("a skipped action skips the app's later actions, with the reason why", () => {
266
+ const plan = planOf(
267
+ compile({
268
+ ctx: context({ branch: "develop", env: "develop" }),
269
+ apps: {
270
+ web: { stages: [{ use: "build", when: { branch: "main" } }, { use: "docker" }, { use: "deploy" }] },
271
+ },
272
+ }),
273
+ );
274
+ const skipOf = (phase: string) =>
275
+ plan.phases.find((each) => each.id === phase)?.stages.find((stage) => stage.app === "web")?.skip;
276
+ expect(skipOf("build")).toEqual({ reason: 'branch "develop" does not match "main"' });
277
+ expect(skipOf("docker")).toEqual({ reason: "build of web is skipped" });
278
+ expect(skipOf("deploy")).toEqual({ reason: "docker of web is skipped" });
279
+ expect(
280
+ plan.phases.find((phase) => phase.id === "deploy")?.stages.find((stage) => stage.app === "api")?.skip,
281
+ ).toBeNull();
282
+ });
283
+
284
+ test("allowed failures, milestones and runtime conditions are taken on actions too", () => {
285
+ const plan = planOf(
286
+ compile({
287
+ apps: {
288
+ web: {
289
+ stages: [
290
+ { use: "build", allowFailure: true, milestone: "verify", when: { changed: ["apps/web/**"] }, retry: 3 },
291
+ { use: "docker" },
292
+ { use: "deploy" },
293
+ ],
294
+ },
295
+ },
296
+ }),
297
+ );
298
+ const build = plan.phases[0]?.stages.find((stage) => stage.app === "web");
299
+ expect(build).toMatchObject({ allowFailure: true, milestone: "verify", runtimeWhen: { changed: ["apps/web/**"] } });
300
+ expect(build?.steps[0]?.retry).toEqual({ attempts: 3, delaySeconds: 0 });
301
+ });
302
+
303
+ test("a workspace matrix copies its stage per app, and a parallel stage hands its condition to each branch", () => {
304
+ const plan = planOf(
305
+ compile({
306
+ ctx: context({ trigger: "redeploy" }),
307
+ workspace: {
308
+ before: [
309
+ {
310
+ name: "Checks",
311
+ allowFailure: true,
312
+ timeout: "10m",
313
+ when: { trigger: "push" },
314
+ parallel: [
315
+ { name: "Lint", sh: "bun lint" },
316
+ { name: "Test", sh: "bun test", timeout: 300 },
317
+ ],
318
+ },
319
+ ],
320
+ after: [
321
+ {
322
+ name: "E2E",
323
+ matrix: "apps",
324
+ sh: "bun run e2e $AKAN_CI_APP",
325
+ when: { app: ["*", "!web"] },
326
+ timeout: "1h",
327
+ },
328
+ ],
329
+ },
330
+ }),
331
+ );
332
+ const checks = plan.phases[0];
333
+ expect(checks).toMatchObject({ id: "checks", timeoutSeconds: 600, milestone: "prepare" });
334
+ expect(checks?.stages.map((stage) => [stage.id, stage.allowFailure, stage.timeoutSeconds, stage.skip])).toEqual([
335
+ ["checks.lint", true, null, { reason: 'trigger "redeploy" is not "push"' }],
336
+ ["checks.test", true, 300, { reason: 'trigger "redeploy" is not "push"' }],
337
+ ]);
338
+ const e2e = plan.phases.at(-1);
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"]]);
341
+ });
342
+
343
+ test("env is workspace, then app, then stage, then step; secrets are each step's union", () => {
344
+ const plan = planOf(
345
+ compile({
346
+ ctx: context({ apps: ["web"] }),
347
+ workspace: { env: { LEVEL: "workspace", ROOT: "1" } },
348
+ apps: {
349
+ web: {
350
+ env: { LEVEL: "app", APP: "1" },
351
+ stages: [
352
+ { use: "build" },
353
+ {
354
+ name: "Test",
355
+ env: { LEVEL: "stage" },
356
+ secrets: ["NPM_TOKEN"],
357
+ steps: [{ sh: "bun test", env: { LEVEL: "step" }, secrets: ["CODECOV_TOKEN"] }],
358
+ },
359
+ ],
360
+ },
361
+ },
362
+ }),
363
+ );
364
+ const step = plan.phases.find((phase) => phase.id === "after-build")?.stages[0]?.steps[0];
365
+ expect(step).toMatchObject({
366
+ env: { LEVEL: "step", ROOT: "1", APP: "1" },
367
+ secrets: ["NPM_TOKEN", "CODECOV_TOKEN"],
368
+ });
369
+ expect(plan.requiredSecrets).toEqual(["CODECOV_TOKEN", "NPM_TOKEN"]);
370
+ });
371
+
372
+ test("function forms receive the run's context, the app's own included", () => {
373
+ const seen: string[] = [];
374
+ const plan = planOf(
375
+ compile({
376
+ ctx: context({ apps: ["web"] }),
377
+ workspace: (ctx) => {
378
+ seen.push(`workspace:${ctx.workspaceApps.join()}:${ctx.branch}`);
379
+ return {};
380
+ },
381
+ apps: {
382
+ web: (ctx) => {
383
+ seen.push(`app:${ctx.app}:${ctx.apps.join()}`);
384
+ return { stages: [{ use: "build" }] };
385
+ },
386
+ },
387
+ }),
388
+ );
389
+ expect(seen).toEqual(["workspace:api,edge,web:main", "app:web:web"]);
390
+ expect(phaseIds(plan)).toEqual(["build"]);
391
+ });
392
+
393
+ test("the plan is a pure function of its input", () => {
394
+ const run = () =>
395
+ JSON.stringify(
396
+ compile({
397
+ apps: { web: { stages: [{ use: "build" }, { name: "Test", sh: "x", secrets: ["B_TOKEN", "A_TOKEN"] }] } },
398
+ }),
399
+ );
400
+ expect(run()).toBe(run());
401
+ });
402
+
403
+ describe("ota", () => {
404
+ const webTargets: Record<string, PipelineNativeTargets> = {
405
+ web: {
406
+ targets: [
407
+ { name: "default", updates: true },
408
+ { name: "pilot", updates: true },
409
+ { name: "kiosk", updates: false },
410
+ ],
411
+ },
412
+ };
413
+ const otaPhase = (plan: PipelinePlan) => plan.phases.find((phase) => phase.action === "ota");
414
+
415
+ test("is opt-in: zero-config never packs an update", () => {
416
+ expect(otaPhase(planOf(compile({ nativeTargets: webTargets })))).toBeUndefined();
417
+ });
418
+
419
+ test("runs after deploy, packing every target with updates for both phones by default", () => {
420
+ const plan = planOf(
421
+ compile({
422
+ nativeTargets: webTargets,
423
+ apps: {
424
+ web: {
425
+ stages: [
426
+ { use: "build" },
427
+ { use: "docker" },
428
+ { use: "deploy" },
429
+ { use: "ota" },
430
+ { name: "Notify", sh: "echo ok" },
431
+ ],
432
+ },
433
+ },
434
+ }),
435
+ );
436
+ expect(phaseIds(plan)).toEqual(["build", "docker", "deploy", "ota", "after-ota"]);
437
+ expect(otaPhase(plan)).toMatchObject({ slot: "ota", milestone: "deploy", name: "OTA", order: "parallel" });
438
+ expect(otaPhase(plan)?.stages).toEqual([
439
+ {
440
+ id: "web:ota",
441
+ name: "web",
442
+ description: null,
443
+ app: "web",
444
+ milestone: "deploy",
445
+ skip: null,
446
+ runtimeWhen: null,
447
+ allowFailure: false,
448
+ timeoutSeconds: null,
449
+ steps: [
450
+ {
451
+ id: "web:ota/ota",
452
+ name: "ota web",
453
+ timeoutSeconds: null,
454
+ retry: null,
455
+ allowFailure: false,
456
+ kind: "action",
457
+ actionVersion: 1,
458
+ action: "ota",
459
+ with: { app: "web", targets: ["default", "pilot"], platforms: ["ios", "android"] },
460
+ },
461
+ ],
462
+ },
463
+ ]);
464
+ });
465
+
466
+ test("an app without deploy packs on its own, and named targets and platforms are kept as written", () => {
467
+ const plan = planOf(
468
+ compile({
469
+ ctx: context({ apps: ["web"] }),
470
+ nativeTargets: webTargets,
471
+ apps: { web: { stages: [{ use: "ota", targets: ["pilot"], platforms: ["android"], timeout: "20m" }] } },
472
+ }),
473
+ );
474
+ expect(phaseIds(plan)).toEqual(["ota"]);
475
+ expect(otaPhase(plan)?.stages[0]).toMatchObject({ skip: null, timeoutSeconds: 1200 });
476
+ expect(otaPhase(plan)?.stages[0]?.steps[0]).toMatchObject({
477
+ with: { app: "web", targets: ["pilot"], platforms: ["android"] },
478
+ });
479
+ });
480
+
481
+ test("is skipped with the app's earlier action, and takes when.changed like any action", () => {
482
+ const plan = planOf(
483
+ compile({
484
+ ctx: context({ branch: "develop", env: "develop", apps: ["web"] }),
485
+ nativeTargets: webTargets,
486
+ apps: {
487
+ web: {
488
+ stages: [
489
+ { use: "build" },
490
+ { use: "deploy", when: { branch: "main" } },
491
+ { use: "ota", when: { changed: ["apps/web/**"] } },
492
+ ],
493
+ },
494
+ },
495
+ }),
496
+ );
497
+ expect(otaPhase(plan)?.stages[0]).toMatchObject({
498
+ skip: { reason: "deploy of web is skipped" },
499
+ runtimeWhen: { changed: ["apps/web/**"] },
500
+ });
501
+ });
502
+
503
+ test("refuses targets without native.updates, unknown targets, and apps whose targets could not be read", () => {
504
+ const ota = (stage: Record<string, unknown>, nativeTargets: Record<string, PipelineNativeTargets> = webTargets) =>
505
+ compile({
506
+ ctx: context({ apps: ["web"] }),
507
+ nativeTargets,
508
+ apps: { web: { stages: [{ use: "ota", ...stage } as never] } },
509
+ });
510
+ const without = ota({ targets: ["default", "kiosk"] });
511
+ expect(without.ok).toBe(false);
512
+ expect(without.diagnostics).toEqual([
513
+ expect.objectContaining({ code: "pipeline.ota.noUpdates", path: "pipeline.stages[0].targets" }),
514
+ ]);
515
+ expect(without.diagnostics[0]?.message).toContain('"kiosk"');
516
+ expect(codes(ota({ targets: ["ghost"] }))).toEqual(["pipeline.ota.unknownTarget"]);
517
+ expect(codes(ota({}, { web: { targets: [{ name: "default", updates: false }] } }))).toEqual([
518
+ "pipeline.ota.noUpdates",
519
+ ]);
520
+ expect(codes(ota({}, { web: { error: "boom" } }))).toEqual(["pipeline.ota.targets"]);
521
+ expect(codes(ota({ platforms: ["macos"] }))).toContain("pipeline.schema.invalid");
522
+ expect(codes(ota({ targets: [] }))).toContain("pipeline.schema.invalid");
523
+ expect(codes(compile({ apps: { web: { stages: [{ use: "build", targets: ["x"] } as never] } } }))).toContain(
524
+ "pipeline.schema.unknownKey",
525
+ );
526
+ expect(
527
+ codes(compile({ nativeTargets: webTargets, apps: { web: { stages: [{ use: "ota" }, { use: "deploy" }] } } })),
528
+ ).toContain("pipeline.action.order");
529
+ });
530
+ });
531
+
532
+ test("the context is echoed, and an unknown or doubled app is refused rather than corrected", () => {
533
+ expect(planOf(compile({ ctx: context({ apps: ["web", "api"] }) })).context.apps).toEqual(["web", "api"]);
534
+ expect(codes(compile({ ctx: context({ apps: ["web", "web"] }) }))).toContain("pipeline.context.duplicateApp");
535
+ expect(codes(compile({ ctx: context({ apps: ["ghost"] }) }))).toContain("pipeline.context.unknownApp");
536
+ });
537
+
538
+ describe("diagnostics", () => {
539
+ const appResult = (pipeline: AppPipelineConfig) =>
540
+ compile({ ctx: context({ apps: ["web"] }), apps: { web: pipeline } });
541
+
542
+ test("action structure", () => {
543
+ expect(codes(appResult({ stages: [{ use: "docker" }, { use: "deploy" }] }))).toContain(
544
+ "pipeline.action.dockerWithoutBuild",
545
+ );
546
+ expect(codes(appResult({ stages: [{ use: "build" }, { use: "build" }] }))).toContain("pipeline.action.duplicate");
547
+ expect(codes(appResult({ stages: [{ use: "build" }, { use: "deploy" }, { use: "docker" }] }))).toContain(
548
+ "pipeline.action.order",
549
+ );
550
+ const deployOnly = appResult({ stages: [{ use: "build" }, { use: "deploy" }] });
551
+ expect(deployOnly.ok).toBe(true);
552
+ expect(codes(deployOnly)).toContain("pipeline.action.deployWithoutDocker");
553
+ const noActions = appResult({ stages: [{ name: "Docs", sh: "bun docs" }] });
554
+ expect(noActions.ok).toBe(true);
555
+ expect(codes(noActions)).toContain("pipeline.app.noActions");
556
+ });
557
+
558
+ test("shape and names", () => {
559
+ expect(codes(appResult({ stages: [{ name: "x", sh: "x", when: { app: "web" } } as never] }))).toContain(
560
+ "pipeline.schema.unknownKey",
561
+ );
562
+ expect(codes(appResult({ stages: [{ name: "x", sh: "x", steps: ["y"] } as never] }))).toContain(
563
+ "pipeline.schema.invalid",
564
+ );
565
+ expect(codes(appResult({ stages: [{ name: "x", sh: "x", cache: ["node_modules"] } as never] }))).toContain(
566
+ "pipeline.schema.unsupported",
567
+ );
568
+ expect(codes(appResult({ stages: [{ name: "x", sh: "x", env: { PATH: "/bin" } }] }))).toContain(
569
+ "pipeline.env.name",
570
+ );
571
+ expect(codes(appResult({ stages: [{ name: "x", sh: "x", secrets: ["lower"] }] }))).toContain(
572
+ "pipeline.secret.name",
573
+ );
574
+ expect(codes(appResult({ stages: [{ name: "x", steps: [{ sh: "x", cwd: "../up" }] }] }))).toContain(
575
+ "pipeline.cwd.outside",
576
+ );
577
+ expect(codes(appResult({ stages: [{ name: "x", sh: "x", timeout: "1.5h" as never }] }))).toContain(
578
+ "pipeline.duration.invalid",
579
+ );
580
+ expect(codes(appResult({ stages: [{ name: "x", sh: "x", when: { branch: "{a,b}" } }] }))).toContain(
581
+ "pipeline.when.pattern",
582
+ );
583
+ expect(
584
+ codes(
585
+ appResult({
586
+ stages: [
587
+ { name: "Test", sh: "a" },
588
+ { name: "test", sh: "b" },
589
+ ],
590
+ }),
591
+ ),
592
+ ).toContain("pipeline.id.duplicate");
593
+ expect(codes(appResult({ stages: [{ name: "!!!", sh: "a" }] }))).toContain("pipeline.id.invalid");
594
+ expect(codes(appResult({ schemaVersion: 2 as never, stages: [] }))).toContain("pipeline.schema.unsupported");
595
+ });
596
+
597
+ test("the workspace scope", () => {
598
+ expect(codes(compile({ workspace: { before: [{ use: "build" } as never] } }))).toContain(
599
+ "pipeline.schema.invalid",
600
+ );
601
+ expect(codes(compile({ workspace: { before: [{ name: "Build", sh: "x" }] } }))).toContain("pipeline.id.reserved");
602
+ expect(codes(compile({ workspace: { post: { unstable: ["x"] } as never } }))).toContain(
603
+ "pipeline.schema.unknownKey",
604
+ );
605
+ });
606
+
607
+ test("loading and sampling", () => {
608
+ const thrown = appResult(() => {
609
+ throw new Error("boom");
610
+ });
611
+ expect(thrown.ok).toBe(false);
612
+ expect(thrown.diagnostics.find((diagnostic) => diagnostic.code === "pipeline.load.failed")?.message).toContain(
613
+ "boom",
614
+ );
615
+ const sampled = compile({
616
+ ctx: context({ apps: ["web"] }),
617
+ apps: { web: () => ({ stages: [] }) },
618
+ sampled: true,
619
+ });
620
+ expect(codes(sampled)).toContain("pipeline.function.sampled");
621
+ });
622
+
623
+ test("milestones that move backwards are a warning", () => {
624
+ const result = appResult({
625
+ stages: [{ use: "build" }, { use: "docker" }, { name: "Late", sh: "x", milestone: "verify" }],
626
+ });
627
+ expect(result.ok).toBe(true);
628
+ expect(codes(result)).toContain("pipeline.milestone.backwards");
629
+ });
630
+ });
631
+ });