@akanjs/devkit 3.1.0-rc.0 → 3.1.0-rc.2

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 +25 -13
  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 +151 -41
  16. package/cloud/updatePackUpload.ts +60 -23
  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 +46 -0
  24. package/executors.ts +26 -23
  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 -0
  35. package/mobile/nativeApp.ts +109 -63
  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
@@ -33,13 +33,17 @@ class BuildBatch {
33
33
  }
34
34
 
35
35
  async run(): Promise<BuildBatchResult> {
36
- if (this.#request.needs.includes("base")) await this.#buildBase();
37
- if (this.#request.needs.includes("csr")) await this.#buildCsr();
38
- if (this.#request.needs.includes("shell")) await this.#buildShell();
39
- if (this.#request.needs.includes("ssr")) await this.#buildSsrRegistry();
36
+ const { needs } = this.#request;
37
+ //? A CSR artifact inlines the stylesheets, so it waits for this batch's compile of them; the registry reads none.
38
+ const cssFirst = needs.includes("css") && needs.includes("csr") && resolveDevCsrMode() === "artifact";
39
+ if (needs.includes("base")) await this.#buildBase();
40
+ if (cssFirst) await this.#buildCss();
41
+ if (needs.includes("csr")) await this.#buildCsr();
42
+ if (needs.includes("shell")) await this.#buildShell();
43
+ if (needs.includes("ssr")) await this.#buildSsrRegistry();
40
44
  // Css before pages: it scans the sources and reads nothing pages produces, so a class edit need not wait for pages.
41
- if (this.#request.needs.includes("css")) await this.#buildCss();
42
- if (this.#request.needs.includes("pages")) await this.#buildPages();
45
+ if (needs.includes("css") && !cssFirst) await this.#buildCss();
46
+ if (needs.includes("pages")) await this.#buildPages();
43
47
  return this.#result;
44
48
  }
45
49
 
@@ -112,7 +116,8 @@ class BuildBatch {
112
116
  }
113
117
 
114
118
  async #rebuildCsrArtifact(started: number): Promise<void> {
115
- await new CsrArtifactBuilder(this.#app, "start", "en", { cssAssets: this.#request.cssAssets }).build();
119
+ const cssAssets = this.#result.cssAssets ?? this.#request.cssAssets;
120
+ await new CsrArtifactBuilder(this.#app, "start", "en", { cssAssets }).build();
116
121
  this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
117
122
  // A CSR tab takes none of the SSR refresh messages, so a rebuilt artifact reaches it only as this reload.
118
123
  if (this.#request.changedFiles.length === 0) return;
@@ -178,6 +178,69 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
178
178
  expect(batches.filter((needs) => needs === "shell")).toHaveLength(1);
179
179
  }, 30_000);
180
180
 
181
+ test("builds the shell again once the css it inlined is replaced, though the generation has not moved", async () => {
182
+ const { root } = track(await createTempApp("demo"));
183
+ const appDir = path.join(root, "apps/demo");
184
+ const ledger = path.join(root, "batches.log");
185
+ const gate = path.join(root, "css-lands");
186
+ const landed = { "": { cssUrl: "/_akan/styles/root-next.css", cssRelPath: "styles/root-next.css" } };
187
+ // The registry's boot build stands in for a save's css compile, held until the first shell is built.
188
+ await writeText(
189
+ path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
190
+ `import fs from "node:fs";
191
+ const { generation, needs, cssAssets } = JSON.parse(process.argv[2]);
192
+ fs.appendFileSync(${JSON.stringify(ledger)}, needs.join(",") + " " + JSON.stringify(cssAssets) + "\\n");
193
+ const orphaned = () => process.exit(1);
194
+ process.on("disconnect", orphaned);
195
+ const deadline = Date.now() + 15_000;
196
+ if (needs.includes("ssr")) while (!fs.existsSync(${JSON.stringify(gate)}) && Date.now() < deadline) await Bun.sleep(20);
197
+ const css = needs.includes("ssr") ? { cssAssets: ${JSON.stringify(landed)} } : {};
198
+ process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] }, ...css } });
199
+ process.off("disconnect", orphaned);
200
+ `,
201
+ );
202
+ await writeText(path.join(appDir, "page/_index.tsx"), "export default () => null;\n");
203
+ const messages: BuilderMessage[] = [];
204
+ const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
205
+ cwd: appDir,
206
+ env: {
207
+ ...process.env,
208
+ AKAN_WORKSPACE_ROOT: root,
209
+ AKAN_PUBLIC_APP_NAME: "demo",
210
+ AKAN_PUBLIC_REPO_NAME: "repo",
211
+ AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
212
+ AKAN_PUBLIC_ENV: "local",
213
+ AKAN_WATCH: "0",
214
+ AKAN_DEV_CSR_PATCHER: "off",
215
+ },
216
+ stdio: ["ignore", "ignore", "pipe"],
217
+ serialization: "advanced",
218
+ ipc: (message: BuilderMessage) => {
219
+ messages.push(message);
220
+ },
221
+ });
222
+ const answers = () => messages.filter((message) => message.type === "build-csr-res");
223
+ try {
224
+ await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
225
+ proc.send({ type: "build-csr", id: 7, reason: "mcp app view", shell: true } satisfies BuilderMessage);
226
+ await until(proc, "the first build-csr-res", () => answers().length === 1);
227
+ await writeText(gate, "");
228
+ await until(proc, "boot-armed", () => messages.some((message) => message.type === "boot-armed"));
229
+ proc.send({ type: "build-csr", id: 8, reason: "mcp app view", shell: true } satisfies BuilderMessage);
230
+ await until(proc, "the second build-csr-res", () => answers().length === 2);
231
+ } finally {
232
+ await writeText(gate, "");
233
+ proc.kill();
234
+ await proc.exited;
235
+ }
236
+ expect(answers()).toEqual([
237
+ { type: "build-csr-res", id: 7, ok: true },
238
+ { type: "build-csr-res", id: 8, ok: true },
239
+ ]);
240
+ const shells = (await Bun.file(ledger).text()).split("\n").filter((line) => line.startsWith("shell "));
241
+ expect(shells).toEqual(["shell null", `shell ${JSON.stringify(landed)}`]);
242
+ }, 30_000);
243
+
181
244
  test("a killed boot build is tried again after the crash window, before boot-armed; a route build's check waits likewise", async () => {
182
245
  // Inside the checkout so the route's client bundle resolves the framework from its source.
183
246
  const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
@@ -63,7 +63,7 @@ class IncrementalBuilder {
63
63
  #autoImportSync: AutoImportSync;
64
64
  #watcher: HmrWatcher | null = null;
65
65
  #generation = 0;
66
- #shellGeneration: number | null = null;
66
+ #shellKey: string | null = null;
67
67
  #discoveredThrough = 0;
68
68
  #csrActive = IncrementalBuilder.#csrArmedByEnv();
69
69
  #csrBundler: CsrDevBundler;
@@ -908,19 +908,21 @@ class IncrementalBuilder {
908
908
  await armed;
909
909
  }
910
910
 
911
- // Asked on every view read and rebuilt only when a save moved the generation, instead of armed per save: a restarted
912
- // builder has no last build to trust, and so rebuilds once on the next read.
911
+ // Asked on every view read and rebuilt only when a save moved the generation or the css it inlines, instead of armed
912
+ // per save: a restarted builder has no last build to trust, and so rebuilds once on the next read.
913
913
  async #buildShell(msg: BuilderCsrReq): Promise<void> {
914
914
  await this.#workQueue.enqueue("build-shell", async (): Promise<void> => {
915
915
  const generation = this.#generation;
916
+ //? A save moves the generation before its css compile lands, so a shell built in between holds the old css.
917
+ const shellKey = `${generation}:${JSON.stringify(this.#artifact.cssAssets)}`;
916
918
  let error: string | undefined;
917
- if (this.#shellGeneration !== generation)
919
+ if (this.#shellKey !== shellKey)
918
920
  try {
919
921
  const result = await this.#batchRunner.run(
920
922
  await this.#batchRequest({ generation, needs: ["shell"], changedFiles: [] }),
921
923
  );
922
924
  error = result.errors.shell;
923
- if (!error) this.#shellGeneration = generation;
925
+ if (!error) this.#shellKey = shellKey;
924
926
  } catch (err) {
925
927
  error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
926
928
  }
@@ -54,6 +54,19 @@ describe("DesktopServerStage", () => {
54
54
  expect(withEnv).toMatchObject({ CLOUD_ORIGIN: "https://cloud.example", AKAN_PUBLIC_OPERATION_MODE: "edge" });
55
55
  });
56
56
 
57
+ test("every key the stage sets over desktop.server.env is one the config refuses there, never silently replaced", () => {
58
+ const config = appConfig();
59
+ const run = { modules: ["@edge"] };
60
+ const owned = Object.keys(DesktopServerStage.env(config, "main", run)).filter(
61
+ (key) => DesktopServerStage.env(config, "main", { ...run, env: { [key]: "mine" } })[key] !== "mine",
62
+ );
63
+ expect(owned).toEqual(expect.arrayContaining(["AKAN_MCP", "AKAN_SHUTDOWN_TIMEOUT_MS", "AKAN_MODULES"]));
64
+ for (const key of owned)
65
+ expect(() => appConfig({ native: { desktop: { server: { env: { [key]: "mine" } } } } })).toThrow(
66
+ `sets ${key}, which the carried server's stage owns`,
67
+ );
68
+ });
69
+
57
70
  test("installs what the image does, minus the RSC renderer and the other modes' drivers", () => {
58
71
  const config = appConfig({ database: { modes: ["single", "cluster"] }, externalLibs: ["@external/runtime"] });
59
72
  const built = config.getProductionPackageJson();
@@ -1,25 +1,23 @@
1
1
  import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import type { ServerPolicy } from "@akanjs/native/api";
4
+ import { toError } from "akanjs/common";
3
5
  import type { AkanAppConfig, NativeEnv } from "../akanConfig";
4
6
  import type { App } from "../commandDecorators";
5
7
  import type { PackageJson } from "../types";
6
8
  import { DesktopBin } from "./desktopBin";
7
9
 
8
10
  /** What a desktop build hands the native runtime as `desktop.server`. */
9
- export interface DesktopServerBundle {
11
+ export interface DesktopServerBundle extends ServerPolicy {
10
12
  dir: string;
11
13
  entry: string;
12
14
  env: Record<string, string>;
13
- restart?: "forever" | number;
14
- watchdog?: { intervalMs: number; failures: number };
15
15
  }
16
16
 
17
17
  /** How the targets that carry one build's server have it run (`desktop.server`, normalized). */
18
- export interface DesktopServerRun {
18
+ export interface DesktopServerRun extends ServerPolicy {
19
19
  modules?: string[];
20
20
  env?: Record<string, string>;
21
- restart?: "forever" | number;
22
- watchdog?: { intervalMs: number; failures: number };
23
21
  }
24
22
 
25
23
  //* A desktop app's carried server is the backend `akan build` wrote into dist, installed on its own as the image's
@@ -153,7 +151,7 @@ export class DesktopServerStage {
153
151
  await this.app.spawn(process.execPath, DesktopServerStage.installArgs(arch), { cwd: this.dir });
154
152
  } catch (error) {
155
153
  throw new Error(
156
- `The desktop app's server could not install its packages in ${this.dir}: it needs the npm registry, or a Bun cache that already holds every one of them.\n${error instanceof Error ? error.message : String(error)}`,
154
+ `The desktop app's server could not install its packages in ${this.dir}: it needs the npm registry, or a Bun cache that already holds every one of them.\n${toError(error).message}`,
157
155
  { cause: error },
158
156
  );
159
157
  }
package/mobile/index.ts CHANGED
@@ -5,7 +5,9 @@ export * from "./nativeApi";
5
5
  export * from "./nativeApp";
6
6
  export * from "./nativeConfig";
7
7
  export * from "./nativePluginFolders";
8
+ export * from "./nativePublicEnv";
8
9
  export * from "./nativeWebDir";
9
10
  export * from "./placeholderAppId";
11
+ export * from "./updatePackFolder";
10
12
  export * from "./updateUploader";
11
13
  export * from "./usageDescriptions";
@@ -7,6 +7,19 @@ import { NativeApi } from "./nativeApi";
7
7
  const makeTempRoot = tempDirs("akan-native-api-");
8
8
  const repoApp = path.resolve(import.meta.dir, "../../../../apps/minimal");
9
9
 
10
+ const installVendoredApi = async (version: string) => {
11
+ const root = await realpath(await makeTempRoot());
12
+ await mkdir(path.join(root, "node_modules/akanjs"), { recursive: true });
13
+ await writeFile(
14
+ path.join(root, "node_modules/akanjs/package.json"),
15
+ JSON.stringify({ name: "akanjs", exports: { "./package.json": "./package.json" } }),
16
+ );
17
+ const entry = NativeApi.entryOf(root);
18
+ await mkdir(path.dirname(entry), { recursive: true });
19
+ await writeFile(entry, `export const API_VERSION = ${JSON.stringify(version)};\n`);
20
+ return root;
21
+ };
22
+
10
23
  describe("NativeApi", () => {
11
24
  test("inside this repo, loads the workspace package and checks its major", async () => {
12
25
  expect(NativeApi.entryOf(repoApp)).toBe(path.resolve(import.meta.dir, "../../native/packages/cli/src/api.ts"));
@@ -27,4 +40,17 @@ describe("NativeApi", () => {
27
40
  );
28
41
  await expect(NativeApi.load(root)).rejects.toThrow("The installed akanjs has no native build");
29
42
  });
43
+
44
+ test("refuses an API older than the one the build calls into, and another major, before anything builds", async () => {
45
+ await expect(NativeApi.load(await installVendoredApi("0.10.0"))).rejects.toThrow(
46
+ "install matching versions of akanjs and @akanjs/cli",
47
+ );
48
+ await expect(NativeApi.load(await installVendoredApi("1.0.0"))).rejects.toThrow(
49
+ "install matching versions of akanjs and @akanjs/cli",
50
+ );
51
+ expect((await NativeApi.load(await installVendoredApi(NativeApi.minimumVersion))).API_VERSION).toBe(
52
+ NativeApi.minimumVersion,
53
+ );
54
+ expect((await NativeApi.load(await installVendoredApi("0.12.0"))).API_VERSION).toBe("0.12.0");
55
+ });
30
56
  });
@@ -6,6 +6,9 @@ export type NativeBuildApiModule = typeof NativeBuildApi;
6
6
  export class NativeApi {
7
7
  //* The build API versions itself; a major this devkit was not written against is refused before anything builds.
8
8
  static readonly supportedMajor = 0;
9
+ //? An update release's `revoke` and `resetSequence` arrived in 0.12.0, and an older API would publish without them;
10
+ //? `requestedDistribution` in 0.11.0; akanjs 3.0.x vendors 0.10.0.
11
+ static readonly minimumVersion = "0.12.0";
9
12
 
10
13
  /** The API of the akanjs the app installed: the workspace package inside this repo, akanjs's vendored copy elsewhere. */
11
14
  static entryOf(appDir: string): string {
@@ -22,9 +25,9 @@ export class NativeApi {
22
25
  throw new Error(`The installed akanjs has no native build at ${entry}; update akanjs.`);
23
26
  const api = (await import(entry)) as NativeBuildApiModule;
24
27
  const major = Number(api.API_VERSION.split(".")[0]);
25
- if (major !== NativeApi.supportedMajor)
28
+ if (major !== NativeApi.supportedMajor || Bun.semver.order(api.API_VERSION, NativeApi.minimumVersion) < 0)
26
29
  throw new Error(
27
- `The native build API is ${api.API_VERSION}, and this akan CLI speaks ${NativeApi.supportedMajor}.x; install matching versions of akanjs and @akanjs/cli.`,
30
+ `The native build API is ${api.API_VERSION}, and this akan CLI speaks ${NativeApi.minimumVersion} or a later ${NativeApi.supportedMajor}.x; install matching versions of akanjs and @akanjs/cli.`,
28
31
  );
29
32
  return api;
30
33
  }
@@ -3,9 +3,11 @@ import path from "node:path";
3
3
  import type { AkanPlugin } from "akanjs";
4
4
  import type { AkanNativeTarget } from "../akanConfig";
5
5
  import type { App } from "../commandDecorators";
6
+ import { CsrArtifactBuilder } from "../frontendBuild/csrArtifactBuilder";
6
7
  import { tempDirs, writeText } from "../testHelpers";
7
8
  import { NativeApp } from "./nativeApp";
8
9
  import { NativeConfig } from "./nativeConfig";
10
+ import { NativePublicEnv } from "./nativePublicEnv";
9
11
 
10
12
  const makeTempRoot = tempDirs("akan-native-app-");
11
13
 
@@ -88,6 +90,139 @@ describe("NativeApp", () => {
88
90
  });
89
91
  });
90
92
 
93
+ describe("the server a release carries, signed or not", () => {
94
+ const releasing = async () => {
95
+ const root = await makeTempRoot();
96
+ const appPath = path.join(root, "apps/portal");
97
+ await writeText(
98
+ path.join(appPath, "env/env.server.main.ts"),
99
+ 'export const env = { security: { jwtSecret: "main-jwt-0123456789" } };\n',
100
+ );
101
+ const server = { dir: path.join(root, "server"), entry: "main.js", env: {} };
102
+ await writeText(path.join(server.dir, "main.js"), 'const jwt = "main-jwt-0123456789";\n');
103
+ const app = {
104
+ ...fakeApp(),
105
+ cwdPath: appPath,
106
+ workspace: { workspaceRoot: root },
107
+ logger: { info: () => undefined, warn: () => undefined },
108
+ } as unknown as App;
109
+ const updates = { url: "https://releases.example.com/portal", publicKey: `${"a".repeat(43)}=` };
110
+ const nativeApp = new NativeApp(app, target({ updates }), "main");
111
+ const built: string[] = [];
112
+ nativeApp.assembleWeb = async () => undefined as never;
113
+ nativeApp.prepare = async () =>
114
+ ({
115
+ config: {},
116
+ api: {
117
+ requestedDistribution: () => ({ notice: null, unused: [] }),
118
+ build: async ({ profile }: { profile: string }) => {
119
+ built.push(profile);
120
+ return { artifacts: [], warnings: [] };
121
+ },
122
+ publishUpdate: async () => {
123
+ built.push("update");
124
+ return {};
125
+ },
126
+ },
127
+ }) as never;
128
+ return { appPath, server, nativeApp, built };
129
+ };
130
+
131
+ test("an unsigned release build and an unsigned update are refused when it holds a secret, a debug build is not searched", async () => {
132
+ const { server, nativeApp, built } = await releasing();
133
+ await expect(nativeApp.build("linux", { server })).rejects.toThrow(
134
+ "main.js holds apps/portal/env/env.server.main.ts security.jwtSecret",
135
+ );
136
+ await expect(nativeApp.publishUpdate("linux", { server })).rejects.toThrow("security.jwtSecret");
137
+ await nativeApp.build("linux", { server, profile: "debug" });
138
+ expect(built).toEqual(["debug"]);
139
+ });
140
+
141
+ test("an env file that cannot be evaluated stops the release instead of going unsearched", async () => {
142
+ const { appPath, server, nativeApp, built } = await releasing();
143
+ await writeText(path.join(server.dir, "main.js"), "\n");
144
+ await writeText(path.join(appPath, "env/env.server.develop.ts"), 'export { env } from "./missing";\n');
145
+ await expect(nativeApp.build("linux", { server })).rejects.toThrow(
146
+ "The server this app carries was not searched for the secret values of apps/portal/env/env.server.develop.ts",
147
+ );
148
+ expect(built).toEqual([]);
149
+ });
150
+ });
151
+
152
+ describe("the public env a build inlined, recorded in its bundle.json", () => {
153
+ const publicEnv = { AKAN_PUBLIC_ENV: "main", AKAN_PUBLIC_SERVE_DOMAIN: "akamir.com" };
154
+ const building = async (inlined: Record<string, string>, updates: object | undefined) => {
155
+ const root = await makeTempRoot();
156
+ const warned: string[] = [];
157
+ const app = {
158
+ ...fakeApp(),
159
+ workspace: { workspaceRoot: root },
160
+ dist: { cwdPath: path.join(root, "dist/apps/portal") },
161
+ logger: { info: () => undefined, warn: (message: string) => warned.push(message) },
162
+ } as unknown as App;
163
+ await CsrArtifactBuilder.writePublicEnv(
164
+ app,
165
+ Object.fromEntries(
166
+ Object.entries({ ...inlined, AKAN_PUBLIC_APP_NAME: "portal", AKAN_PUBLIC_RENDER_ENV: "csr" }).map(
167
+ ([key, value]) => [`process.env.${key}`, JSON.stringify(value)],
168
+ ),
169
+ ),
170
+ );
171
+ const nativeApp = new NativeApp(app, target(updates ? { updates } : {}), "main");
172
+ const built: string[] = [];
173
+ const writeBundle = async (dir: string) =>
174
+ await writeText(path.join(dir, "bundle.json"), JSON.stringify({ schema: 1, web: { hash: "w", files: 1 } }));
175
+ nativeApp.assembleWeb = async () => undefined as never;
176
+ nativeApp.prepare = async () =>
177
+ ({
178
+ config: { updates: { channel: "production" } },
179
+ api: {
180
+ packUpdate: async ({ out }: { out: string }) => {
181
+ built.push("pack");
182
+ await writeBundle(out);
183
+ return { out, manifest: { app: "com.portal.app", version: "1.0.0", nativeApi: "n", files: [] } };
184
+ },
185
+ release: async ({ outDir }: { outDir: string }) => {
186
+ built.push("release");
187
+ await writeBundle(outDir);
188
+ return {};
189
+ },
190
+ },
191
+ }) as never;
192
+ return { root, nativeApp, built, warned };
193
+ };
194
+ const updates = { url: "https://releases.example.com/portal", publicKey: `${"a".repeat(43)}=` };
195
+
196
+ test("a pack and a release record what the CSR build inlined, and the pack's record carries its hash", async () => {
197
+ const { root, nativeApp } = await building(publicEnv, updates);
198
+ const { pack } = await nativeApp.packUpdate("android", { out: path.join(root, "out") });
199
+ const bundle = await Bun.file(path.join(root, "out/bundle.json")).json();
200
+ expect(bundle.web).toEqual({
201
+ hash: "w",
202
+ files: 1,
203
+ publicEnv,
204
+ publicEnvHash: NativePublicEnv.hash(publicEnv),
205
+ });
206
+ expect(pack.publicEnvHash).toBe(NativePublicEnv.hash(publicEnv));
207
+ await nativeApp.releaseIos();
208
+ const release = await Bun.file(path.join(nativeApp.outDir("ios"), "bundle.json")).json();
209
+ expect(release.web.publicEnvHash).toBe(NativePublicEnv.hash(publicEnv));
210
+ });
211
+
212
+ test("an env an update cannot carry stops a target with updates before it builds, and only warns without", async () => {
213
+ const refused = { ...publicEnv, AKAN_PUBLIC_MAP_TOKEN: "pk.x" };
214
+ const withUpdates = await building(refused, updates);
215
+ await expect(withUpdates.nativeApp.releaseIos()).rejects.toThrow("AKAN_PUBLIC_MAP_TOKEN names a token");
216
+ expect(withUpdates.built).toEqual([]);
217
+ const without = await building(refused, undefined);
218
+ await without.nativeApp.releaseIos();
219
+ expect(without.built).toEqual(["release"]);
220
+ expect(without.warned.join("\n")).toContain("bundle.json records none");
221
+ const release = await Bun.file(path.join(without.nativeApp.outDir("ios"), "bundle.json")).json();
222
+ expect(release.web.publicEnv).toBeUndefined();
223
+ });
224
+ });
225
+
91
226
  test("a dev build bundles no page a release build left, and builds apart from the release it would empty", async () => {
92
227
  const root = await makeTempRoot();
93
228
  const app = {