@akanjs/devkit 3.1.0-rc.0 → 3.1.0-rc.10
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.
- package/abstractDoc.ts +1 -1
- package/agentsIndex.test.ts +4 -1
- package/akanConfig/akanConfig.test.ts +51 -5
- package/akanConfig/akanConfig.ts +33 -14
- package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
- package/akanConfig/akanWorkspaceConfig.ts +1 -1
- package/akanContext.ts +4 -4
- package/akanMcpContract.ts +1 -1
- package/applicationBuildRunner.test.ts +57 -2
- package/applicationBuildRunner.ts +15 -6
- package/artifact/implicitRootLayout.test.ts +2 -0
- package/artifact/implicitRootLayout.ts +1 -0
- package/biome.base.json +1 -11
- package/biomeBase.ts +1 -1
- package/cloud/cloudApi.ts +25 -13
- package/cloud/cloudHttpError.test.ts +32 -0
- package/cloud/cloudHttpError.ts +54 -0
- package/cloud/cloudUpdates.ts +138 -0
- package/cloud/configLock.ts +2 -1
- package/cloud/globalConfig.test.ts +17 -0
- package/cloud/globalConfig.ts +9 -2
- package/cloud/index.ts +3 -0
- package/cloud/pipelineSecrets.ts +114 -0
- package/cloud/updatePackUpload.test.ts +151 -41
- package/cloud/updatePackUpload.ts +60 -23
- package/codeAgent/agent/CodeAgentProxy.ts +2 -2
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +3 -1
- package/codeAgent/tools/WebToolPack.ts +4 -4
- package/codeAgent/tools/akanCodePlugins.test.ts +20 -16
- package/commandDecorators/argMeta.ts +11 -2
- package/commandDecorators/command.ts +45 -10
- package/commandDecorators/commandBuilder.ts +6 -1
- package/commandDecorators/commandDecorators.test.ts +205 -4
- package/commandDecorators/exitCodeError.ts +15 -0
- package/commandDecorators/index.ts +1 -0
- package/createTunnel.test.ts +14 -0
- package/createTunnel.ts +3 -1
- package/dependencyScanner.ts +1 -1
- package/devkitUtils.test.ts +1 -1
- package/executors.test.ts +131 -3
- package/executors.ts +56 -36
- package/frontendBuild/autoImportSync.test.ts +3 -1
- package/frontendBuild/clientEntryDiscovery.ts +1 -1
- package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
- package/frontendBuild/csrArtifactBuilder.ts +23 -4
- package/frontendBuild/csrDevPatcher.test.ts +4 -1
- package/frontendBuild/cssBlockParser.test.ts +123 -0
- package/frontendBuild/cssBlockParser.ts +132 -0
- package/frontendBuild/cssCompiler.test.ts +53 -0
- package/frontendBuild/cssCompiler.ts +8 -4
- package/frontendBuild/fontOptimizer.ts +2 -1
- package/frontendBuild/frontendBuild.test.ts +14 -0
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
- package/frontendBuild/pagesBundleBuilder.ts +64 -9
- package/frontendBuild/routeClientBuilder.ts +1 -1
- package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
- package/frontendBuild/themeValidator.test.ts +83 -2
- package/frontendBuild/themeValidator.ts +37 -15
- package/getRelatedCnsts.ts +2 -1
- package/incrementalBuilder/buildBatch.proc.ts +12 -7
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
- package/integration/devStabilityHarness.ts +5 -3
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
- package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
- package/mobile/desktopServerStage.test.ts +13 -0
- package/mobile/desktopServerStage.ts +5 -7
- package/mobile/index.ts +2 -0
- package/mobile/mobileTarget.ts +1 -2
- package/mobile/nativeApi.test.ts +26 -0
- package/mobile/nativeApi.ts +5 -2
- package/mobile/nativeApp.test.ts +135 -0
- package/mobile/nativeApp.ts +109 -63
- package/mobile/nativeConfig.test.ts +3 -0
- package/mobile/nativeConfig.ts +5 -3
- package/mobile/nativePublicEnv.test.ts +131 -0
- package/mobile/nativePublicEnv.ts +116 -0
- package/mobile/nativeWebDir.ts +3 -2
- package/mobile/serverBundleSecrets.test.ts +79 -0
- package/mobile/serverBundleSecrets.ts +60 -16
- package/mobile/updatePackFolder.test.ts +98 -0
- package/mobile/updatePackFolder.ts +131 -0
- package/mobile/updateUploader.test.ts +115 -22
- package/mobile/updateUploader.ts +77 -10
- package/package.json +20 -20
- package/piDependencyPins.test.ts +18 -19
- package/pipeline/PipelineCompiler.test.ts +157 -1
- package/pipeline/PipelineCompiler.ts +80 -46
- package/pipeline/PipelineDefinition.test.ts +9 -0
- package/pipeline/PipelineDefinition.ts +4 -3
- package/pipeline/PipelineDryRun.test.ts +27 -0
- package/pipeline/PipelineDryRun.ts +2 -2
- package/pipeline/PipelineInputReader.ts +157 -253
- package/pipeline/PipelineRules.ts +92 -0
- package/pipeline/index.ts +1 -0
- package/platformTest/LinuxDockerTarget.ts +2 -1
- package/platformTest/PlatformTestTarget.ts +7 -5
- package/platformTest/WindowsSshTarget.ts +2 -1
- package/qualityScanner.test.ts +5 -3
- package/qualityScanner.ts +1 -1
- package/recipeScanner.test.ts +9 -3
- package/scanInfo.ts +1 -1
- package/slicePlanner.ts +5 -3
- package/subspace.test.ts +90 -19
- package/subspace.ts +52 -500
- package/subspaceClone.ts +362 -0
- package/subspaceConfig.ts +1 -2
- package/subspaceReport.ts +162 -0
- package/testHelpers.ts +2 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/browserDirnamePlugin.test.ts +88 -0
- package/transforms/browserDirnamePlugin.ts +25 -0
- package/transforms/rscUseClientTransform.ts +2 -2
- package/transforms/serverEnvPlugin.test.ts +76 -0
- package/transforms/serverEnvPlugin.ts +9 -25
- package/transforms/serverEnvProfile.test.ts +189 -0
- package/transforms/serverEnvProfile.ts +324 -60
- package/transforms/transforms.test.ts +18 -2
- package/transforms/tsconfigPackageResolver.test.ts +9 -0
- package/typecheck/projectTypecheck.ts +6 -5
- package/types.ts +1 -0
- package/workflow/artifacts.ts +1 -1
- package/workflow/rolloutGate.test.ts +5 -1
- package/workflow/source.ts +2 -2
package/abstractDoc.ts
CHANGED
|
@@ -26,7 +26,7 @@ export class AbstractDoc {
|
|
|
26
26
|
static async findAll(sys: SysExecutor, { module }: { module?: string | null } = {}) {
|
|
27
27
|
const filePaths = (await sys.getAllFiles(`${AbstractDoc.#facetRoots}/**/*${AbstractDoc.suffix}`))
|
|
28
28
|
.map((filePath) => filePath.split(path.sep).join("/"))
|
|
29
|
-
.sort();
|
|
29
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
30
30
|
const docs = await Promise.all(filePaths.map((filePath) => AbstractDoc.read(sys, filePath)));
|
|
31
31
|
if (!module) return docs;
|
|
32
32
|
return docs.filter((doc) => doc.moduleName === module || doc.folderName === module);
|
package/agentsIndex.test.ts
CHANGED
|
@@ -86,6 +86,9 @@ describe("collectScopeRecipeSources", () => {
|
|
|
86
86
|
await write("libs/shared/ui/Recipe/panel.ts", `export const panelRecipe = recipe(tv({ base: "y" }));`);
|
|
87
87
|
await write("pkgs/akanjs/ui/recipe/buttonRecipe.ts", `export const buttonRecipe = recipe(tv({ base: "z" }));`);
|
|
88
88
|
const sources = await collectScopeRecipeSources(root, { type: "app", name: "minimal" }, ["shared"]);
|
|
89
|
-
expect(sources.map((source) => source.importFrom).sort()).toEqual([
|
|
89
|
+
expect(sources.map((source) => source.importFrom).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
|
|
90
|
+
"@apps/minimal/ui",
|
|
91
|
+
"@libs/shared/ui",
|
|
92
|
+
]);
|
|
90
93
|
});
|
|
91
94
|
});
|
|
@@ -518,10 +518,14 @@ describe("AkanAppConfig", () => {
|
|
|
518
518
|
watchdog: { intervalMs: 10_000, failures: 5 },
|
|
519
519
|
});
|
|
520
520
|
expect(() => resolve({ env: { SLACK_WEBHOOK_URL: "https://hooks" } })).toThrow("SLACK_WEBHOOK_URL");
|
|
521
|
-
|
|
521
|
+
for (const key of ["AKAN_MODULES", "AKAN_MCP", "AKAN_SHUTDOWN_TIMEOUT_MS"])
|
|
522
|
+
expect(() => resolve({ env: { [key]: "1" } })).toThrow(`sets ${key}, which the carried server's stage owns`);
|
|
522
523
|
expect(() => resolve({ env: { PORT: 1 } })).toThrow("must be an object of strings");
|
|
523
524
|
expect(() => resolve({ restart: 0 })).toThrow('must be "forever" or a count');
|
|
524
|
-
|
|
525
|
+
for (const watchdog of [{ intervalMs: -1 }, null, 5, [], { interval: 5_000 }])
|
|
526
|
+
expect(() => resolve({ watchdog })).toThrow(
|
|
527
|
+
"native.desktop.server.watchdog in apps/portal/akan.config.ts must be",
|
|
528
|
+
);
|
|
525
529
|
});
|
|
526
530
|
|
|
527
531
|
test("an app without basePaths has one target, default, with no basePath and the section's settings", () => {
|
|
@@ -630,6 +634,30 @@ describe("AkanAppConfig", () => {
|
|
|
630
634
|
).toThrow("native.updates in apps/portal/akan.config.ts has no url or publicKeys.");
|
|
631
635
|
});
|
|
632
636
|
|
|
637
|
+
test("refuses updates holding both publicKey and publicKeys, a target's own key beside the section's list included", () => {
|
|
638
|
+
const url = "https://releases.example.com/portal";
|
|
639
|
+
const resolve = (native: object) =>
|
|
640
|
+
new AkanAppConfig(app, [], packageJson, { native } as never, baseDevEnv).native.targets;
|
|
641
|
+
|
|
642
|
+
expect(() =>
|
|
643
|
+
resolve({
|
|
644
|
+
updates: { url, publicKeys: ["a="] },
|
|
645
|
+
targets: { store: {}, pilot: { updates: { publicKey: "p=" } } },
|
|
646
|
+
}),
|
|
647
|
+
).toThrow(
|
|
648
|
+
"native.targets.pilot.updates in apps/portal/akan.config.ts has both publicKey and publicKeys, native.updates merged in",
|
|
649
|
+
);
|
|
650
|
+
expect(() => resolve({ updates: { url, publicKey: "a=", publicKeys: ["b="] } })).toThrow(
|
|
651
|
+
"native.updates in apps/portal/akan.config.ts has both publicKey and publicKeys: give one.",
|
|
652
|
+
);
|
|
653
|
+
expect(
|
|
654
|
+
resolve({
|
|
655
|
+
updates: { url, publicKeys: ["a="] },
|
|
656
|
+
targets: { store: {}, pilot: { updates: { publicKeys: ["p="] } } },
|
|
657
|
+
}).pilot.updates,
|
|
658
|
+
).toEqual({ url, publicKeys: ["p="] });
|
|
659
|
+
});
|
|
660
|
+
|
|
633
661
|
test("merges the desktop settings field by field, the target winning", () => {
|
|
634
662
|
const config = new AkanAppConfig(
|
|
635
663
|
app,
|
|
@@ -949,7 +977,7 @@ describe("pipeline and workspace keys in app and lib configs", () => {
|
|
|
949
977
|
"export default { subspace: { subspaces: [] } };\n",
|
|
950
978
|
"export default {};\n",
|
|
951
979
|
);
|
|
952
|
-
expect(loading).rejects.toThrow("a workspace setting");
|
|
980
|
+
await expect(loading).rejects.toThrow("a workspace setting");
|
|
953
981
|
});
|
|
954
982
|
|
|
955
983
|
test("a lib takes neither a pipeline nor a subspace", async () => {
|
|
@@ -957,11 +985,29 @@ describe("pipeline and workspace keys in app and lib configs", () => {
|
|
|
957
985
|
try {
|
|
958
986
|
const lib = { name: "extlib", cwdPath: root } as never;
|
|
959
987
|
fs.writeFileSync(path.join(root, "akan.config.ts"), "export default { pipeline: { stages: [] } };\n");
|
|
960
|
-
expect(AkanLibConfig.from(lib)).rejects.toThrow("which a lib does not take");
|
|
988
|
+
await expect(AkanLibConfig.from(lib)).rejects.toThrow("which a lib does not take");
|
|
961
989
|
fs.writeFileSync(path.join(root, "akan.config.ts"), "export default { subspace: { subspaces: [] } };\n");
|
|
962
|
-
expect(AkanLibConfig.from(lib, { bustImportCache: true })).rejects.toThrow("a workspace setting");
|
|
990
|
+
await expect(AkanLibConfig.from(lib, { bustImportCache: true })).rejects.toThrow("a workspace setting");
|
|
963
991
|
} finally {
|
|
964
992
|
fs.rmSync(root, { recursive: true, force: true });
|
|
965
993
|
}
|
|
966
994
|
});
|
|
995
|
+
|
|
996
|
+
test("a lib's refused key fails the app's load instead of skipping the lib and its docker steps", async () => {
|
|
997
|
+
const loading = loadExtAppConfig(
|
|
998
|
+
"akan-config-lib-refused-",
|
|
999
|
+
"export default {};\n",
|
|
1000
|
+
"export default { docker: { preRuns: ['apt-get install -y ffmpeg'] }, pipeline: { stages: [] } };\n",
|
|
1001
|
+
);
|
|
1002
|
+
await expect(loading).rejects.toThrow("libs/extlib/akan.config.ts declares `pipeline`, which a lib does not take");
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test("a lib config that fails to load is still skipped with a warning, the app loading without it", async () => {
|
|
1006
|
+
const config = await loadExtAppConfig(
|
|
1007
|
+
"akan-config-lib-broken-",
|
|
1008
|
+
"export default { externalLibs: ['shiki'] };\n",
|
|
1009
|
+
'throw new Error("half-written");\nexport default {};\n',
|
|
1010
|
+
);
|
|
1011
|
+
expect(config.externalLibs).toEqual(["shiki"]);
|
|
1012
|
+
});
|
|
967
1013
|
});
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { ServerPolicy } from "@akanjs/native/api";
|
|
4
5
|
import type { AkanPlugin } from "akanjs";
|
|
5
6
|
import { DatabaseModes, normalizeRoutePrefix } from "akanjs/base";
|
|
6
|
-
import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
|
|
7
|
+
import { type AkanI18nConfig, resolveAkanI18nConfig, trimSlashes } from "akanjs/common";
|
|
7
8
|
import type { AkanImageConfig } from "akanjs/server";
|
|
8
9
|
import type { App, Lib } from "../commandDecorators";
|
|
9
10
|
import { LibExecutor, WorkspaceExecutor } from "../executors";
|
|
@@ -152,6 +153,8 @@ const dedupeDockerRuns = (runs: DockerRun[]): DockerRun[] => {
|
|
|
152
153
|
return [...byKey.values()];
|
|
153
154
|
};
|
|
154
155
|
|
|
156
|
+
class LibConfigRefusal extends Error {}
|
|
157
|
+
|
|
155
158
|
export class AkanAppConfig implements AppConfigResult {
|
|
156
159
|
app: App;
|
|
157
160
|
rootPackageJson: PackageJson;
|
|
@@ -285,6 +288,11 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
285
288
|
throw new Error(
|
|
286
289
|
`${where}.updates in ${configPath} has no url or publicKeys${named ? "; give them in native.updates or the target's own" : ""}.`,
|
|
287
290
|
);
|
|
291
|
+
//? NativeConfig prefers publicKeys to publicKey, so a target's own key beside the section's list would be lost.
|
|
292
|
+
if (updates?.publicKey !== undefined && updates.publicKeys !== undefined)
|
|
293
|
+
throw new Error(
|
|
294
|
+
`${where}.updates in ${configPath} has both publicKey and publicKeys${named ? ", native.updates merged in: give one, in native.updates' own form when overriding its keys" : ": give one"}.`,
|
|
295
|
+
);
|
|
288
296
|
AkanAppConfig.#assertFiles(own ?? {}, where, configPath);
|
|
289
297
|
const {
|
|
290
298
|
basePath: _basePath,
|
|
@@ -370,13 +378,16 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
370
378
|
},
|
|
371
379
|
};
|
|
372
380
|
}
|
|
373
|
-
//? The stage owns these: the operation mode, the database mode
|
|
381
|
+
//? The stage owns these: the operation mode, the database mode, what is served and the shutdown budget the shell's
|
|
382
|
+
//? quit waits on are what make it the carried server.
|
|
374
383
|
static readonly #stagedServerEnv = new Set([
|
|
375
384
|
"AKAN_PUBLIC_OPERATION_MODE",
|
|
376
385
|
"AKAN_DATABASE_MODE",
|
|
377
386
|
"AKAN_DATABASE_MODES",
|
|
378
387
|
"AKAN_SSR",
|
|
379
388
|
"AKAN_CSR",
|
|
389
|
+
"AKAN_MCP",
|
|
390
|
+
"AKAN_SHUTDOWN_TIMEOUT_MS",
|
|
380
391
|
"AKAN_MODULES",
|
|
381
392
|
]);
|
|
382
393
|
static #desktopServerEnv(env: unknown, where: string, configPath: string): Record<string, string> {
|
|
@@ -395,19 +406,25 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
395
406
|
);
|
|
396
407
|
return env as Record<string, string>;
|
|
397
408
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
configPath: string,
|
|
402
|
-
): { intervalMs: number; failures: number } | null {
|
|
409
|
+
//? The akan.config.ts shape, which takes `true` and fills in what is left out; the launcher reads what this returns
|
|
410
|
+
//? with serverPolicyOf in @akanjs/native, which devkit cannot load while it reads a config.
|
|
411
|
+
static #desktopServerWatchdog(watchdog: unknown, where: string, configPath: string): ServerPolicy["watchdog"] | null {
|
|
403
412
|
if (watchdog === undefined || watchdog === false) return null;
|
|
404
|
-
const
|
|
405
|
-
const whole = (value: unknown) => Number.isInteger(value) && (value as number) > 0;
|
|
406
|
-
if (
|
|
413
|
+
const options = watchdog === true ? {} : watchdog;
|
|
414
|
+
const whole = (value: unknown) => value === undefined || (Number.isInteger(value) && (value as number) > 0);
|
|
415
|
+
if (
|
|
416
|
+
!AkanAppConfig.#isPlainObject(options) ||
|
|
417
|
+
Object.keys(options).some((key) => key !== "intervalMs" && key !== "failures") ||
|
|
418
|
+
!whole(options.intervalMs) ||
|
|
419
|
+
!whole(options.failures)
|
|
420
|
+
)
|
|
407
421
|
throw new Error(
|
|
408
422
|
`${where}.watchdog in ${configPath} must be true or { intervalMs, failures }, whole numbers from 1.`,
|
|
409
423
|
);
|
|
410
|
-
return {
|
|
424
|
+
return {
|
|
425
|
+
intervalMs: (options.intervalMs as number | undefined) ?? 10_000,
|
|
426
|
+
failures: (options.failures as number | undefined) ?? 3,
|
|
427
|
+
};
|
|
411
428
|
}
|
|
412
429
|
static #platformIndexPath<T extends { indexPath?: string }>(section: T): T {
|
|
413
430
|
const { indexPath: rawIndexPath, ...rest } = section;
|
|
@@ -550,7 +567,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
550
567
|
}
|
|
551
568
|
#applyRoutes(routes: AkanRouteConfig[] = []) {
|
|
552
569
|
for (const route of routes) {
|
|
553
|
-
const basePath = route.basePath ? route.basePath
|
|
570
|
+
const basePath = route.basePath ? trimSlashes(route.basePath) : null;
|
|
554
571
|
if (basePath !== null) this.basePaths.add(basePath);
|
|
555
572
|
const domains = basePath !== null ? this.subRoutes.getOrInsert(basePath, new Set()) : this.domains;
|
|
556
573
|
for (const branch of Object.keys(route.domains)) this.branches.add(branch);
|
|
@@ -685,6 +702,8 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
685
702
|
LibExecutor.from(app, libName)
|
|
686
703
|
.getConfig({ refresh: bustImportCache })
|
|
687
704
|
.catch((error: unknown) => {
|
|
705
|
+
//? Skipping a refused lib config would build the image without its docker steps, and the build would pass.
|
|
706
|
+
if (error instanceof LibConfigRefusal) throw error;
|
|
688
707
|
app.logger.warn(`Skipped libs/${libName}/akan.config.ts contributions: ${String(error)}`);
|
|
689
708
|
return null;
|
|
690
709
|
}),
|
|
@@ -843,11 +862,11 @@ export class AkanLibConfig implements LibConfigResult {
|
|
|
843
862
|
const resolved = typeof configImp === "function" ? configImp(lib) : configImp;
|
|
844
863
|
const { plugins, ...config } = (resolved ?? {}) as LibConfigDeclaration & { plugins?: AkanPlugin[] };
|
|
845
864
|
if ("pipeline" in config)
|
|
846
|
-
throw new
|
|
865
|
+
throw new LibConfigRefusal(
|
|
847
866
|
`libs/${lib.name}/akan.config.ts declares \`pipeline\`, which a lib does not take: pipelines belong to apps and the workspace root, and a lib's build-time work goes in an akan build plugin.`,
|
|
848
867
|
);
|
|
849
868
|
if ("subspace" in config)
|
|
850
|
-
throw new
|
|
869
|
+
throw new LibConfigRefusal(
|
|
851
870
|
`libs/${lib.name}/akan.config.ts declares \`subspace\`, a workspace setting: it belongs in the workspace root akan.config.ts.`,
|
|
852
871
|
);
|
|
853
872
|
return new AkanLibConfig(lib, config, plugins ?? []);
|
|
@@ -51,7 +51,7 @@ describe("AkanWorkspaceConfig", () => {
|
|
|
51
51
|
expect(hostOf(undefined)).toBeNull();
|
|
52
52
|
expect(hostOf({})).toBeNull();
|
|
53
53
|
expect(hostOf({ host: "https://cloud.example.com/" })).toBe("https://cloud.example.com");
|
|
54
|
-
expect(hostOf({ host: "https://example.com/
|
|
54
|
+
expect(hostOf({ host: "https://example.com/control//" })).toBe("https://example.com/control");
|
|
55
55
|
expect(hostOf({ host: "http://localhost:8283" })).toBe("http://localhost:8283");
|
|
56
56
|
expect(() => hostOf({ host: "http://cloud.example.com" })).toThrow("https only");
|
|
57
57
|
expect(() => hostOf({ host: "cloud.example.com" })).toThrow("is not a URL");
|
|
@@ -59,21 +59,21 @@ describe("AkanWorkspaceConfig", () => {
|
|
|
59
59
|
expect(() => hostOf({ host: "https://cloud.example.com", token: "x" })).toThrow("keys it does not take: token");
|
|
60
60
|
|
|
61
61
|
const workspace = (cloudHost: string | null) => ({ getConfig: async () => ({ cloudHost }) });
|
|
62
|
-
expect(await GlobalConfig.hostFor(workspace("https://
|
|
62
|
+
expect(await GlobalConfig.hostFor(workspace("https://control.example.com"), "https://given.example.com")).toBe(
|
|
63
63
|
"https://given.example.com",
|
|
64
64
|
);
|
|
65
|
-
expect(await GlobalConfig.hostFor(workspace("https://
|
|
66
|
-
"https://
|
|
65
|
+
expect(await GlobalConfig.hostFor(workspace("https://control.example.com"), null)).toBe(
|
|
66
|
+
"https://control.example.com",
|
|
67
67
|
);
|
|
68
68
|
expect(await GlobalConfig.hostFor(workspace(null))).toBe(GlobalConfig.akanCloudHost);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
test("refuses app settings and a default export that is not an object", async () => {
|
|
72
72
|
await withWorkspace({ "akan.config.ts": 'export default { docker: { image: "x" } };\n' }, async (workspace) => {
|
|
73
|
-
expect(AkanWorkspaceConfig.from(workspace)).rejects.toThrow("declares `docker`, which it does not take");
|
|
73
|
+
await expect(AkanWorkspaceConfig.from(workspace)).rejects.toThrow("declares `docker`, which it does not take");
|
|
74
74
|
});
|
|
75
75
|
await withWorkspace({ "akan.config.ts": "export default () => ({});\n" }, async (workspace) => {
|
|
76
|
-
expect(AkanWorkspaceConfig.from(workspace)).rejects.toThrow("must default-export a WorkspaceConfig object");
|
|
76
|
+
await expect(AkanWorkspaceConfig.from(workspace)).rejects.toThrow("must default-export a WorkspaceConfig object");
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
79
|
});
|
|
@@ -4,7 +4,7 @@ import type { WorkspaceExecutor } from "../executors";
|
|
|
4
4
|
import { FileSys } from "../fileSys";
|
|
5
5
|
import { AkanAppConfig } from "./akanConfig";
|
|
6
6
|
|
|
7
|
-
/** The workspace root's own `akan.config.ts`: settings that belong to no app.
|
|
7
|
+
/** The workspace root's own `akan.config.ts`: settings that belong to no app. */
|
|
8
8
|
export class AkanWorkspaceConfig {
|
|
9
9
|
static readonly fileName = "akan.config.ts";
|
|
10
10
|
static readonly #keys = ["subspace", "pipeline", "cloud"];
|
package/akanContext.ts
CHANGED
|
@@ -209,7 +209,7 @@ export const upsertCodexMcpServerBlock = (
|
|
|
209
209
|
const match = existing.match(codexAkanTablePattern);
|
|
210
210
|
if (!match) {
|
|
211
211
|
if (!existing.trim()) return nextBlock;
|
|
212
|
-
return `${existing.
|
|
212
|
+
return `${existing.trimEnd()}\n\n${nextBlock}`;
|
|
213
213
|
}
|
|
214
214
|
if (match[0].trim() === nextBlock.trim()) return existing;
|
|
215
215
|
if (!force)
|
|
@@ -847,8 +847,8 @@ export class AkanContextAnalyzer {
|
|
|
847
847
|
const listed = listedByImport(sectionOf(agentsMd, "## Recipes")).get("akanjs/ui") ?? new Set<string>();
|
|
848
848
|
pushIndexDiagnostic(
|
|
849
849
|
"AGENTS.md",
|
|
850
|
-
[...frameworkDeclared].filter((name) => !listed.has(name)).sort(),
|
|
851
|
-
[...listed].filter((name) => !frameworkDeclared.has(name)).sort(),
|
|
850
|
+
[...frameworkDeclared].filter((name) => !listed.has(name)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
851
|
+
[...listed].filter((name) => !frameworkDeclared.has(name)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
852
852
|
"akan agent install agents-md",
|
|
853
853
|
);
|
|
854
854
|
}
|
|
@@ -859,7 +859,7 @@ export class AkanContextAnalyzer {
|
|
|
859
859
|
.catch(() => "");
|
|
860
860
|
const groups = listedByImport(sectionOf(scopeMd, "## Recipes In Scope"));
|
|
861
861
|
const ownListed = groups.get(`@${sys.path}/ui`) ?? new Set<string>();
|
|
862
|
-
const missing = [...own].filter((name) => !ownListed.has(name)).sort();
|
|
862
|
+
const missing = [...own].filter((name) => !ownListed.has(name)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
863
863
|
// Every listed name — the scope's own and its dependency libs' — must still exist at its owner.
|
|
864
864
|
const stale = [...groups.entries()]
|
|
865
865
|
.flatMap(([importFrom, names]) =>
|
package/akanMcpContract.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AkanAppConfig } from "./akanConfig";
|
|
6
6
|
import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS, ApplicationBuildRunner } from "./applicationBuildRunner";
|
|
7
7
|
import type { App } from "./commandDecorators";
|
|
8
|
-
import { Executor } from "./executors";
|
|
8
|
+
import { AppExecutor, Executor } from "./executors";
|
|
9
|
+
import { bundleDefine } from "./frontendBuild/bundleDefine";
|
|
10
|
+
import { createTempApp } from "./testHelpers";
|
|
9
11
|
|
|
10
12
|
describe("ApplicationBuildRunner", () => {
|
|
11
13
|
test("externalizes Akan optional backend dependencies", () => {
|
|
@@ -78,4 +80,57 @@ console.info(new SampleService().name);
|
|
|
78
80
|
await rm(root, { recursive: true, force: true });
|
|
79
81
|
}
|
|
80
82
|
});
|
|
83
|
+
|
|
84
|
+
test("bakes into the route bundles the env their server env is, not the shell's that the root .env outranks", async () => {
|
|
85
|
+
const { root, workspace } = await createTempApp("portal");
|
|
86
|
+
const shellEnv = process.env.AKAN_PUBLIC_ENV;
|
|
87
|
+
const stoppedBeforePhases = (app: AppExecutor) => {
|
|
88
|
+
app.prepareCommand = async () => {
|
|
89
|
+
throw new Error("stopped before the phases");
|
|
90
|
+
};
|
|
91
|
+
return app;
|
|
92
|
+
};
|
|
93
|
+
const bakedEnv = (app: AppExecutor) =>
|
|
94
|
+
(bundleDefine(app, "build", "ssr") as { [key: string]: string })["process.env.AKAN_PUBLIC_ENV"];
|
|
95
|
+
try {
|
|
96
|
+
process.env.AKAN_PUBLIC_ENV = "main";
|
|
97
|
+
const app = stoppedBeforePhases(AppExecutor.from(workspace, "portal"));
|
|
98
|
+
await expect(new ApplicationBuildRunner(app).build()).rejects.toThrow("stopped before the phases");
|
|
99
|
+
expect(bakedEnv(app)).toBe(JSON.stringify("local"));
|
|
100
|
+
|
|
101
|
+
process.env.AKAN_PUBLIC_ENV = "main";
|
|
102
|
+
const desktop = stoppedBeforePhases(AppExecutor.from(workspace, "portal"));
|
|
103
|
+
await expect(new ApplicationBuildRunner(desktop, { environment: "debug" }).build()).rejects.toThrow(
|
|
104
|
+
"stopped before the phases",
|
|
105
|
+
);
|
|
106
|
+
expect(bakedEnv(desktop)).toBe(JSON.stringify("debug"));
|
|
107
|
+
} finally {
|
|
108
|
+
if (shellEnv === undefined) delete process.env.AKAN_PUBLIC_ENV;
|
|
109
|
+
else process.env.AKAN_PUBLIC_ENV = shellEnv;
|
|
110
|
+
await rm(root, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a build for a real environment stops before any phase when the serve domain is only the local default", async () => {
|
|
115
|
+
const { root, workspace } = await createTempApp("portal");
|
|
116
|
+
await writeFile(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=repo\n");
|
|
117
|
+
const shellDomain = process.env.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
118
|
+
delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
119
|
+
const app = AppExecutor.from(workspace, "portal");
|
|
120
|
+
let prepared = false;
|
|
121
|
+
app.prepareCommand = async () => {
|
|
122
|
+
prepared = true;
|
|
123
|
+
throw new Error("prepared");
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
await expect(new ApplicationBuildRunner(app, { environment: "main" }).build()).rejects.toThrow(
|
|
127
|
+
'akan build for the "main" environment needs AKAN_PUBLIC_SERVE_DOMAIN (would be "localhost") set',
|
|
128
|
+
);
|
|
129
|
+
expect(prepared).toBe(false);
|
|
130
|
+
await expect(new ApplicationBuildRunner(app, { environment: "local" }).build()).rejects.toThrow("prepared");
|
|
131
|
+
} finally {
|
|
132
|
+
if (shellDomain !== undefined) process.env.AKAN_PUBLIC_SERVE_DOMAIN = shellDomain;
|
|
133
|
+
await rm(root, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
81
136
|
});
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ApplicationBuildResult,
|
|
7
7
|
} from "./applicationBuildReporter";
|
|
8
8
|
import type { App } from "./commandDecorators";
|
|
9
|
+
import { WorkspaceExecutor } from "./executors";
|
|
9
10
|
import {
|
|
10
11
|
AllRoutesBuilder,
|
|
11
12
|
CsrArtifactBuilder,
|
|
@@ -100,13 +101,13 @@ export class ApplicationBuildRunner {
|
|
|
100
101
|
// serial build is needed because of Bun.build is unstable for parallel build
|
|
101
102
|
this.#spinner = spinner;
|
|
102
103
|
const { web, assets, baseDevEnv } = await this.#app.getConfig();
|
|
104
|
+
const environment = this.#environment ?? baseDevEnv.env;
|
|
105
|
+
WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, "akan build");
|
|
106
|
+
//? Bundles bake AKAN_PUBLIC_ENV from process.env, where the shell outranks the .env that picked the server env.
|
|
107
|
+
process.env.AKAN_PUBLIC_ENV = environment;
|
|
103
108
|
//? Before any phase: a profile that would ship a secret stops the build, not the image after it.
|
|
104
109
|
if (this.#profile)
|
|
105
|
-
await ServerEnvProfile.assertShippable({
|
|
106
|
-
envDir: this.#envDir,
|
|
107
|
-
environment: this.#environment ?? baseDevEnv.env,
|
|
108
|
-
profile: this.#profile,
|
|
109
|
-
});
|
|
110
|
+
await ServerEnvProfile.assertShippable({ envDir: this.#envDir, environment, profile: this.#profile });
|
|
110
111
|
await this.#runPhase("prepare", "Preparing output directory", () => this.#app.prepareCommand("build"));
|
|
111
112
|
if (!this.#fast) await this.#runPhase("typecheck", "Typechecking", () => this.typecheck());
|
|
112
113
|
await this.#runPhase(
|
|
@@ -363,9 +364,17 @@ void run().catch((error) => {
|
|
|
363
364
|
return { base, allRoutes };
|
|
364
365
|
}
|
|
365
366
|
|
|
367
|
+
//? Opt-in and outside `dist/`: a metafile is the size of the module graph, and `dist/` is what the image copies.
|
|
366
368
|
async #buildOrThrow(label: string, config: Bun.BuildConfig): Promise<Bun.BuildOutput> {
|
|
367
|
-
const
|
|
369
|
+
const metafileDir = process.env.AKAN_BUILD_METAFILE
|
|
370
|
+
? path.join(this.#app.workspace.workspaceRoot, "local", "build-meta", path.basename(this.#app.cwdPath))
|
|
371
|
+
: null;
|
|
372
|
+
const result = await Bun.build(metafileDir ? { ...config, metafile: true } : config);
|
|
368
373
|
if (!result.success) throw new AggregateError(result.logs, `[${label}] Bun.build failed`);
|
|
374
|
+
if (metafileDir && result.metafile) {
|
|
375
|
+
const entry = path.parse(String(config.entrypoints[0] ?? label)).name;
|
|
376
|
+
await Bun.write(path.join(metafileDir, `${entry}.json`), JSON.stringify(result.metafile));
|
|
377
|
+
}
|
|
369
378
|
return result;
|
|
370
379
|
}
|
|
371
380
|
|
|
@@ -46,6 +46,8 @@ describe("resolveSsrPageEntries", () => {
|
|
|
46
46
|
expect(generatedSource).toContain("export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;");
|
|
47
47
|
expect(generatedSource).toContain("export const Error = userLayout.Error ?? inheritedLayout.Error;");
|
|
48
48
|
expect(generatedSource).toContain("export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;");
|
|
49
|
+
// The ancestor root is its own level of the stack, so inheriting its loading would nest it inside itself.
|
|
50
|
+
expect(generatedSource).toContain("export const Loading = userLayout.Loading;");
|
|
49
51
|
expect(generatedSource).toContain(
|
|
50
52
|
"<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>",
|
|
51
53
|
);
|
|
@@ -201,6 +201,7 @@ async function writeGeneratedRootLayoutFile(opts: {
|
|
|
201
201
|
export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
202
202
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
203
203
|
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
|
204
|
+
export const Loading = userLayout.Loading;
|
|
204
205
|
|
|
205
206
|
${layoutSignature}({ children, params, searchParams }: LayoutProps) {
|
|
206
207
|
${layoutBinding} return `;
|
package/biome.base.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$schema": "https://biomejs.dev/schemas/2.5.
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
|
|
3
3
|
"vcs": {
|
|
4
4
|
"enabled": true,
|
|
5
5
|
"clientKind": "git",
|
|
@@ -85,16 +85,6 @@
|
|
|
85
85
|
"overrides": [
|
|
86
86
|
{
|
|
87
87
|
"includes": [
|
|
88
|
-
"**/env.client.local.ts",
|
|
89
|
-
"**/env.client.debug.ts",
|
|
90
|
-
"**/env.client.develop.ts",
|
|
91
|
-
"**/env.client.main.ts",
|
|
92
|
-
"**/env.client.testing.ts",
|
|
93
|
-
"**/env.server.local.ts",
|
|
94
|
-
"**/env.server.develop.ts",
|
|
95
|
-
"**/env.server.main.ts",
|
|
96
|
-
"**/env.server.testing.ts",
|
|
97
|
-
"**/env.server.type.ts",
|
|
98
88
|
"apps/*/lib/cnst.ts",
|
|
99
89
|
"apps/*/lib/dict.ts",
|
|
100
90
|
"apps/*/lib/db.ts",
|
package/biomeBase.ts
CHANGED
|
@@ -3,4 +3,4 @@ export const biomeBaseConfig = "@akanjs/devkit/biome.base.json";
|
|
|
3
3
|
|
|
4
4
|
// Pinned: Biome moves rules between groups across minors and a stale position is a hard config error. Bump it with
|
|
5
5
|
// `biome.base.json` in one commit, running `biome migrate --write` in the workspace root and in this package.
|
|
6
|
-
export const biomeVersion = "2.5.
|
|
6
|
+
export const biomeVersion = "2.5.14";
|
package/cloud/cloudApi.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Workspace } from "../commandDecorators";
|
|
2
|
+
import { CloudHttpError } from "./cloudHttpError";
|
|
2
3
|
import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
|
|
3
4
|
import { GlobalConfig } from "./globalConfig";
|
|
4
5
|
|
|
@@ -25,7 +26,7 @@ class HttpClient {
|
|
|
25
26
|
const response = await fetch(`${this.baseUrl}${url}`, {
|
|
26
27
|
headers: { ...this.headers, ...headers },
|
|
27
28
|
});
|
|
28
|
-
if (!response.ok) throw
|
|
29
|
+
if (!response.ok) throw await CloudHttpError.of(response, url);
|
|
29
30
|
await Bun.write(localPath, response);
|
|
30
31
|
}
|
|
31
32
|
async post<T>(url: string, data: unknown, { headers, signal }: HttpRequestOptions = {}): Promise<T> {
|
|
@@ -42,9 +43,8 @@ class HttpClient {
|
|
|
42
43
|
}
|
|
43
44
|
// A missing route answers an HTML error page, so the status is read before the body is parsed as JSON.
|
|
44
45
|
static async #body<T>(response: Response, url: string): Promise<T> {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return JSON.parse(text) as T;
|
|
46
|
+
if (!response.ok) throw await CloudHttpError.of(response, url);
|
|
47
|
+
return JSON.parse(await response.text()) as T;
|
|
48
48
|
}
|
|
49
49
|
setHeaders(headers: Record<string, string>) {
|
|
50
50
|
Object.assign(this.headers, headers);
|
|
@@ -71,10 +71,10 @@ export interface PipelineSecretSummary {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export interface PipelineSecretListing extends PipelineSecretSummary {
|
|
74
|
-
updatedBy: string;
|
|
74
|
+
updatedBy: string | null;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/** What
|
|
77
|
+
/** What Akan Cloud answers for an uploaded pack or a registered binary; a pack's release also says how it was taken. */
|
|
78
78
|
export interface CloudRecord {
|
|
79
79
|
id: string;
|
|
80
80
|
status?: string;
|
|
@@ -113,10 +113,19 @@ export interface TunnelSummary {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
export class CloudApi {
|
|
116
|
-
|
|
117
|
-
static
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
//* The project's guards answer by status: 401 no sign-in it accepts, 403 not allowed, 400 a refused request.
|
|
117
|
+
static refusal(error: unknown, who: string, { unavailable }: { unavailable?: string } = {}) {
|
|
118
|
+
if (!(error instanceof CloudHttpError)) return error;
|
|
119
|
+
if (error.status === 401)
|
|
120
|
+
return new Error("The server did not accept your sign-in: run `akan login`, then try again.", { cause: error });
|
|
121
|
+
if (error.status === 403) return new Error(`The project refused: ${who}.`, { cause: error });
|
|
122
|
+
if (error.status === 400)
|
|
123
|
+
return new Error(`The project refused the request${error.error ? `: ${error.error}` : ""}.`, { cause: error });
|
|
124
|
+
//? A proxy, or the gateway with no replica ready, answers 503 too; only the server's own names a domain Err key.
|
|
125
|
+
const { errKey } = error;
|
|
126
|
+
if (error.status === 503 && unavailable && errKey && errKey !== "base.error.serverUnavailable")
|
|
127
|
+
return new Error(`${unavailable}: ${errKey}.`, { cause: error });
|
|
128
|
+
return error;
|
|
120
129
|
}
|
|
121
130
|
readonly #api: HttpClient;
|
|
122
131
|
#accessToken: AccessToken | null = null;
|
|
@@ -184,7 +193,8 @@ export class CloudApi {
|
|
|
184
193
|
target: string;
|
|
185
194
|
distribution: AppBinaryDistribution;
|
|
186
195
|
bundle: Blob;
|
|
187
|
-
pack
|
|
196
|
+
/** The pack staged with UpdatePackUpload.stage. */
|
|
197
|
+
packUploadId: string;
|
|
188
198
|
channel?: string;
|
|
189
199
|
},
|
|
190
200
|
) {
|
|
@@ -192,8 +202,10 @@ export class CloudApi {
|
|
|
192
202
|
for (const key of ["appName", "target", "distribution"] as const) formData.append(key, binary[key]);
|
|
193
203
|
if (binary.channel) formData.append("channel", binary.channel);
|
|
194
204
|
formData.append("bundle", binary.bundle, "bundle.json");
|
|
195
|
-
|
|
196
|
-
return await this.#api.post<CloudRecord>(`/registerAppBinary/${encodeURIComponent(devProjectId)}`, formData
|
|
205
|
+
formData.append("packUploadId", binary.packUploadId);
|
|
206
|
+
return await this.#api.post<CloudRecord>(`/registerAppBinary/${encodeURIComponent(devProjectId)}`, formData, {
|
|
207
|
+
signal: AbortSignal.timeout(11 * 60_000),
|
|
208
|
+
});
|
|
197
209
|
}
|
|
198
210
|
async downloadEnv(devProjectId: string): Promise<unknown> {
|
|
199
211
|
const localPath = `${this.#workspace.workspaceRoot}/local/env.tar`;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CloudHttpError } from "./cloudHttpError";
|
|
3
|
+
|
|
4
|
+
describe("CloudHttpError", () => {
|
|
5
|
+
test("reads the server's Err key from the whole body, while the message keeps 200 characters of it", () => {
|
|
6
|
+
const body = JSON.stringify({ details: "x".repeat(300), error: "release.error.uploadEmbedded", statusCode: 409 });
|
|
7
|
+
const error = new CloudHttpError(409, "Conflict", "/commitUpdateUpload/p", body);
|
|
8
|
+
|
|
9
|
+
expect(error.status).toBe(409);
|
|
10
|
+
expect(error.errKey).toBe("release.error.uploadEmbedded");
|
|
11
|
+
expect(error.message).toBe(`409 Conflict from /commitUpdateUpload/p: ${body.slice(0, 200)}`);
|
|
12
|
+
expect(CloudHttpError.statusOf(error)).toBe(409);
|
|
13
|
+
expect(CloudHttpError.statusOf(new Error("409 Conflict from /x: {}"))).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("names no Err key for a proxy's page or an error that is not one", () => {
|
|
17
|
+
expect(new CloudHttpError(503, "Service Unavailable", "/x", "<html>503</html>").errKey).toBeNull();
|
|
18
|
+
const plain = new CloudHttpError(400, "Bad Request", "/x", '{"error":"Bad Request"}');
|
|
19
|
+
expect(plain.error).toBe("Bad Request");
|
|
20
|
+
expect(plain.errKey).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("takes Retry-After as seconds or as a date, and nothing else", () => {
|
|
24
|
+
expect(CloudHttpError.retryAfterMs("7")).toBe(7000);
|
|
25
|
+
expect(CloudHttpError.retryAfterMs(new Date(Date.now() + 60_000).toUTCString())).toBeGreaterThan(55_000);
|
|
26
|
+
expect(CloudHttpError.retryAfterMs(new Date(Date.now() - 60_000).toUTCString())).toBe(0);
|
|
27
|
+
expect(CloudHttpError.retryAfterMs("soon")).toBeNull();
|
|
28
|
+
expect(CloudHttpError.retryAfterMs(null)).toBeNull();
|
|
29
|
+
const headers = new Headers({ "Retry-After": "3" });
|
|
30
|
+
expect(new CloudHttpError(429, "Too Many Requests", "/x", "", headers).retryAfterMs).toBe(3000);
|
|
31
|
+
});
|
|
32
|
+
});
|