@akanjs/devkit 3.1.0-rc.1 → 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 +22 -15
- 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 +100 -45
- package/cloud/updatePackUpload.ts +38 -18
- 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 -33
- package/mobile/nativeApp.ts +109 -73
- 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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isRecord } from "akanjs/common";
|
|
2
|
+
|
|
3
|
+
//? The body is kept whole for reading the server's answer; only the message is cut, as it lands in a terminal.
|
|
4
|
+
export class CloudHttpError extends Error {
|
|
5
|
+
static readonly #errKey = /^[\w$-]+\.error\.[\w$-]+$/;
|
|
6
|
+
|
|
7
|
+
override readonly name = "CloudHttpError";
|
|
8
|
+
|
|
9
|
+
constructor(
|
|
10
|
+
readonly status: number,
|
|
11
|
+
readonly statusText: string,
|
|
12
|
+
readonly url: string,
|
|
13
|
+
readonly body: string,
|
|
14
|
+
readonly headers: Headers = new Headers(),
|
|
15
|
+
) {
|
|
16
|
+
super(`${status} ${statusText} from ${url}: ${body.slice(0, 200)}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static async of(response: Response, url: string) {
|
|
20
|
+
return new CloudHttpError(response.status, response.statusText, url, await response.text(), response.headers);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static statusOf(error: unknown) {
|
|
24
|
+
return error instanceof CloudHttpError ? error.status : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The `error` of a JSON refusal: an Err key from the server itself, or whatever a proxy put there. */
|
|
28
|
+
get error(): string | null {
|
|
29
|
+
try {
|
|
30
|
+
const parsed: unknown = JSON.parse(this.body);
|
|
31
|
+
return isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `error` when it is an Err key (`release.error.uploadCommitInProgress`), which only the server's own answer names. */
|
|
38
|
+
get errKey(): string | null {
|
|
39
|
+
const { error } = this;
|
|
40
|
+
return error && CloudHttpError.#errKey.test(error) ? error : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get retryAfterMs(): number | null {
|
|
44
|
+
return CloudHttpError.retryAfterMs(this.headers.get("retry-after"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Retry-After in milliseconds from now: whole seconds, or an HTTP date. */
|
|
48
|
+
static retryAfterMs(header: string | null): number | null {
|
|
49
|
+
const value = header?.trim() ?? "";
|
|
50
|
+
if (!value) return null;
|
|
51
|
+
const ms = /^\d+$/.test(value) ? Number(value) * 1000 : Date.parse(value) - Date.now();
|
|
52
|
+
return Number.isFinite(ms) ? Math.max(0, ms) : null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Logger } from "akanjs/common";
|
|
3
|
+
import type { NativeEnv } from "../akanConfig";
|
|
4
|
+
import type { App } from "../commandDecorators";
|
|
5
|
+
import { ExitCodeError } from "../commandDecorators/exitCodeError";
|
|
6
|
+
import { type MobilePlatform, resolveMobileTargets } from "../mobile/mobileTarget";
|
|
7
|
+
import { NativeApp } from "../mobile/nativeApp";
|
|
8
|
+
import { NativePublicEnv } from "../mobile/nativePublicEnv";
|
|
9
|
+
import { type NativeUpdatePack, UpdatePackFolder } from "../mobile/updatePackFolder";
|
|
10
|
+
import { type AppBinaryDistribution, CloudApi } from "./cloudApi";
|
|
11
|
+
import { UpdatePackUpload } from "./updatePackUpload";
|
|
12
|
+
|
|
13
|
+
//* A phone app's over-the-air updates through an Akan Cloud project, which keeps the signing key: a pack uploaded for
|
|
14
|
+
//* it to sign and publish, and the store build a pack of the same commit is embedded in.
|
|
15
|
+
export class CloudUpdates {
|
|
16
|
+
constructor(
|
|
17
|
+
readonly cloudApi: CloudApi,
|
|
18
|
+
readonly devProjectId: string,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
/** The bundle.json the target's last `akan release-<platform>` wrote. */
|
|
22
|
+
static async storeBundlePath(app: App, target: string | undefined, platform: MobilePlatform) {
|
|
23
|
+
const [mobileTarget] = await resolveMobileTargets(app, target);
|
|
24
|
+
if (!mobileTarget) throw new Error(`Native target '${target}' was not found.`);
|
|
25
|
+
return path.join(new NativeApp(app, mobileTarget).outDir(platform), UpdatePackFolder.bundleFile);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A pack `akan pack-update` wrote, refused unless it is this app's and agrees with every flag given beside it. */
|
|
29
|
+
static async readPack(
|
|
30
|
+
app: App,
|
|
31
|
+
dir: string,
|
|
32
|
+
{
|
|
33
|
+
platform,
|
|
34
|
+
target,
|
|
35
|
+
env,
|
|
36
|
+
allowLocalRelease,
|
|
37
|
+
}: { platform: MobilePlatform | null; target: string | null; env: NativeEnv | null; allowLocalRelease: boolean },
|
|
38
|
+
) {
|
|
39
|
+
const pack = await UpdatePackFolder.read(dir);
|
|
40
|
+
const what = `The pack in ${pack.out} is ${pack.app} ${pack.target} ${pack.platform} --env ${pack.env}`;
|
|
41
|
+
if (pack.app !== app.name) throw new Error(`${what}, not ${app.name}'s.`);
|
|
42
|
+
if (platform && platform !== pack.platform) throw new Error(`${what}, not ${platform}.`);
|
|
43
|
+
if (env && env !== pack.env) throw new Error(`${what}, not --env ${env}.`);
|
|
44
|
+
if (target) {
|
|
45
|
+
const targets = (await resolveMobileTargets(app, target)).map(({ name }) => name);
|
|
46
|
+
if (targets.length !== 1 || targets[0] !== pack.target)
|
|
47
|
+
throw new Error(`${what}, not --target ${target} (${targets.join(", ")}).`);
|
|
48
|
+
}
|
|
49
|
+
if (pack.env === "local" && !allowLocalRelease)
|
|
50
|
+
throw new Error(
|
|
51
|
+
`${what}, and a local pack is blocked. Pass --allow-local-release only for explicit local release testing.`,
|
|
52
|
+
);
|
|
53
|
+
return pack;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async push(pack: NativeUpdatePack, { channel, notes }: { channel: string | null; notes: string | null }) {
|
|
57
|
+
const target = channel ?? pack.channel;
|
|
58
|
+
return await UpdatePackFolder.withArchive(pack, async (archive) => {
|
|
59
|
+
const release = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
|
|
60
|
+
.send(archive, { appName: pack.app, target: pack.target, platform: pack.platform, channel: target, notes })
|
|
61
|
+
.catch((error: unknown) => {
|
|
62
|
+
throw CloudApi.refusal(error, "a pack is uploaded by a project member signed in with `akan login`");
|
|
63
|
+
});
|
|
64
|
+
const label = `${pack.app} ${pack.target} ${pack.platform} on channel ${target}: release ${release.id}`;
|
|
65
|
+
if (release.status === "rejected")
|
|
66
|
+
throw new ExitCodeError(
|
|
67
|
+
`The project rejected ${label}:\n${(release.problems ?? []).map((problem) => ` - ${problem}`).join("\n")}`,
|
|
68
|
+
2,
|
|
69
|
+
);
|
|
70
|
+
if (release.status === "published")
|
|
71
|
+
Logger.rawLog(`Published ${label} (${pack.files} files, native API ${pack.nativeApi}).`);
|
|
72
|
+
else
|
|
73
|
+
Logger.rawLog(
|
|
74
|
+
`Staged ${label} (${pack.files} files, native API ${pack.nativeApi}). Publish it from the project's release screen.`,
|
|
75
|
+
);
|
|
76
|
+
return release;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
//? The store build first, then a pack of the same commit: a server that registers the binary checks the pack runs in it.
|
|
81
|
+
async registerBinary(
|
|
82
|
+
app: App,
|
|
83
|
+
pack: NativeUpdatePack,
|
|
84
|
+
{ distribution, env }: { distribution: AppBinaryDistribution; env: NativeEnv },
|
|
85
|
+
) {
|
|
86
|
+
const { cloudApi, devProjectId } = this;
|
|
87
|
+
const bundlePath = await CloudUpdates.storeBundlePath(app, pack.target, pack.platform);
|
|
88
|
+
const bundleFile = Bun.file(bundlePath);
|
|
89
|
+
const rebuild = `akan release-${pack.platform} ${app.name} --target ${pack.target} --env ${env}`;
|
|
90
|
+
if (!(await bundleFile.exists())) throw new Error(`No store build at ${bundlePath}: run \`${rebuild}\` first.`);
|
|
91
|
+
const bundle = await UpdatePackFolder.readBundle(bundlePath);
|
|
92
|
+
if (!bundle.updates)
|
|
93
|
+
throw new Error(`The store build at ${bundlePath} takes no updates: give the target native.updates.`);
|
|
94
|
+
const packBundle = path.join(pack.out, UpdatePackFolder.bundleFile);
|
|
95
|
+
const envProblem = await NativePublicEnv.problemAgainst(bundlePath, packBundle);
|
|
96
|
+
if (envProblem) throw new ExitCodeError(`The pack in ${pack.out} cannot run in the store build: ${envProblem}.`, 2);
|
|
97
|
+
//? Every native build of the target rewrites this file, so a debug or another env's build replaces the release's.
|
|
98
|
+
if (bundle.web.hash !== pack.webHash)
|
|
99
|
+
throw new Error(
|
|
100
|
+
`The store build at ${bundlePath} is not this commit's --env ${env} build: its web bundle differs from the pack's. Run \`${rebuild}\`, then register that build.`,
|
|
101
|
+
);
|
|
102
|
+
const compat = await NativeApp.compareBundles(app.cwdPath, bundlePath, packBundle);
|
|
103
|
+
const verdict = UpdatePackFolder.storeBuildVerdict(
|
|
104
|
+
{ out: pack.out, compat: { against: bundlePath, ...compat } },
|
|
105
|
+
`Run \`${rebuild}\`, then register that build.`,
|
|
106
|
+
);
|
|
107
|
+
if (verdict) throw verdict;
|
|
108
|
+
if (bundle.updates.channel !== pack.channel)
|
|
109
|
+
throw new Error(
|
|
110
|
+
`The store build at ${bundlePath} reads updates channel ${bundle.updates.channel}, and this commit's --env ${env} pack is for ${pack.channel}. Run \`${rebuild}\`, then register that build.`,
|
|
111
|
+
);
|
|
112
|
+
const { channel } = pack;
|
|
113
|
+
const upload = new UpdatePackUpload(cloudApi, devProjectId);
|
|
114
|
+
return await UpdatePackFolder.withArchive(pack, async (archive) => {
|
|
115
|
+
const binary = await upload
|
|
116
|
+
.stage(archive, { appName: pack.app, target: pack.target, platform: pack.platform, channel, notes: null })
|
|
117
|
+
.then((packUploadId) =>
|
|
118
|
+
upload.settle(() =>
|
|
119
|
+
cloudApi.registerAppBinary(devProjectId, {
|
|
120
|
+
appName: pack.app,
|
|
121
|
+
target: pack.target,
|
|
122
|
+
distribution,
|
|
123
|
+
bundle: bundleFile,
|
|
124
|
+
packUploadId,
|
|
125
|
+
channel,
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
.catch((error: unknown) => {
|
|
130
|
+
throw CloudApi.refusal(error, "a binary is registered by a project member signed in with `akan login`");
|
|
131
|
+
});
|
|
132
|
+
Logger.rawLog(
|
|
133
|
+
`Registered ${pack.app} ${pack.target} ${pack.platform} (${distribution}): binary ${binary.id}, native API ${pack.nativeApi}.`,
|
|
134
|
+
);
|
|
135
|
+
return binary;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
package/cloud/configLock.ts
CHANGED
|
@@ -36,7 +36,8 @@ export class ConfigLock {
|
|
|
36
36
|
|
|
37
37
|
static async #take(lockPath: string) {
|
|
38
38
|
const handle = await open(lockPath, "wx", 0o600).catch((error: NodeJS.ErrnoException) => {
|
|
39
|
-
|
|
39
|
+
// Windows answers EPERM, not EEXIST, while a released lock file is still pending delete behind an open handle.
|
|
40
|
+
if (error.code === "EEXIST" || (process.platform === "win32" && error.code === "EPERM")) return null;
|
|
40
41
|
throw error;
|
|
41
42
|
});
|
|
42
43
|
if (!handle) return false;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { GlobalConfig } from "./globalConfig";
|
|
3
|
+
|
|
4
|
+
const workspace = (cloudHost: string | null) => ({ getConfig: async () => ({ cloudHost }) });
|
|
5
|
+
|
|
6
|
+
describe("GlobalConfig", () => {
|
|
7
|
+
test("hostFor spells --host the way the workspace's cloud.host is spelled, so one login serves both", async () => {
|
|
8
|
+
for (const given of ["https://cloud.example.com/", "https://Cloud.Example.com:443", "https://cloud.example.com//"])
|
|
9
|
+
expect(await GlobalConfig.hostFor(workspace(null), given)).toBe("https://cloud.example.com");
|
|
10
|
+
expect(await GlobalConfig.hostFor(workspace("https://cloud.example.com"), "https://cloud.example.com/")).toBe(
|
|
11
|
+
await GlobalConfig.hostFor(workspace("https://cloud.example.com"), null),
|
|
12
|
+
);
|
|
13
|
+
expect(await GlobalConfig.hostFor(workspace(null), "https://example.com/cloud/")).toBe("https://example.com/cloud");
|
|
14
|
+
expect(await GlobalConfig.hostFor(workspace(null), "http://localhost:8283/")).toBe("http://localhost:8283");
|
|
15
|
+
expect(await GlobalConfig.hostFor(workspace(null))).toBe(GlobalConfig.akanCloudHost);
|
|
16
|
+
});
|
|
17
|
+
});
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { trimTrailingSlashes } from "akanjs/common";
|
|
2
3
|
import dayjs from "dayjs";
|
|
3
4
|
import { FileSys } from "../fileSys";
|
|
4
5
|
import { ConfigLock } from "./configLock";
|
|
@@ -54,8 +55,14 @@ export class GlobalConfig {
|
|
|
54
55
|
});
|
|
55
56
|
}
|
|
56
57
|
static async hostFor(workspace: { getConfig(): Promise<{ cloudHost: string | null }> }, host?: string | null) {
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
return GlobalConfig.#normalizeHost(host || ((await workspace.getConfig()).cloudHost ?? GlobalConfig.akanCloudHost));
|
|
59
|
+
}
|
|
60
|
+
//? A login is stored under the host string, so `--host` and the workspace's `cloud.host` must spell one cloud alike.
|
|
61
|
+
static #normalizeHost(host: string) {
|
|
62
|
+
const url = URL.canParse(host) ? new URL(host) : null;
|
|
63
|
+
return url && (url.protocol === "https:" || url.protocol === "http:")
|
|
64
|
+
? `${url.origin}${trimTrailingSlashes(url.pathname)}`
|
|
65
|
+
: trimTrailingSlashes(host);
|
|
59
66
|
}
|
|
60
67
|
static async getHostConfig(host = GlobalConfig.akanCloudHost): Promise<HostConfig> {
|
|
61
68
|
const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
|
package/cloud/index.ts
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { capitalize, Logger, PipelineNames } from "akanjs/common";
|
|
2
|
+
import { CloudApi, type PipelineSecretListing } from "./cloudApi";
|
|
3
|
+
|
|
4
|
+
//* The named secrets an Akan Cloud project hands its pipeline: written and removed by name, listed without values.
|
|
5
|
+
export class PipelineSecrets {
|
|
6
|
+
static readonly #secretBytes = 64 * 1024;
|
|
7
|
+
//? The branches Akan Cloud deploys (its BranchType): a secret scoped to any other is refused by the server.
|
|
8
|
+
static readonly #secretBranches = ["debug", "develop", "main"];
|
|
9
|
+
static readonly #secretsUnavailable = "The server is not configured for pipeline secrets yet";
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
readonly cloudApi: CloudApi,
|
|
13
|
+
readonly devProjectId: string,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
//* Never an argument: a value on the command line lands in shell history and in any `ps` listing.
|
|
17
|
+
static async readValue(name: string, file: string | null) {
|
|
18
|
+
const raw = file
|
|
19
|
+
? await Bun.file(file).text()
|
|
20
|
+
: process.stdin.isTTY
|
|
21
|
+
? await (await import("@inquirer/prompts")).password({
|
|
22
|
+
message: `Value of ${name} (hidden; it can never be read back)`,
|
|
23
|
+
mask: "*",
|
|
24
|
+
})
|
|
25
|
+
: await PipelineSecrets.#readStdin(name);
|
|
26
|
+
const value = raw.replace(/\r?\n$/, "");
|
|
27
|
+
if (!value) throw new Error(`The value of ${name} is empty.`);
|
|
28
|
+
const bytes = new TextEncoder().encode(value).length;
|
|
29
|
+
if (bytes > PipelineSecrets.#secretBytes)
|
|
30
|
+
throw new Error(`The value of ${name} is ${bytes} bytes, over ${PipelineSecrets.#secretBytes}.`);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static async #readStdin(name: string) {
|
|
35
|
+
process.stderr.write(`Reading the value of ${name} from stdin until it closes (or pass --file <path>).\n`);
|
|
36
|
+
return await Bun.stdin.text();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async upload({ name, branch, value }: { name: string; branch: string | null; value: string }) {
|
|
40
|
+
PipelineSecrets.assertName(name);
|
|
41
|
+
PipelineSecrets.assertBranch(branch);
|
|
42
|
+
const saved = await this.cloudApi
|
|
43
|
+
.uploadSecret(this.devProjectId, { scope: branch ?? "*", name, value })
|
|
44
|
+
.catch((error: unknown) => {
|
|
45
|
+
throw CloudApi.refusal(error, "a secret is written by a project member signed in with `akan login`", {
|
|
46
|
+
unavailable: PipelineSecrets.#secretsUnavailable,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
Logger.rawLog(
|
|
50
|
+
`Saved ${saved.name} for ${saved.scope === "*" ? "every branch" : `branch ${saved.scope}`}. Builds that start from now on use it.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async list(branch: string | null) {
|
|
55
|
+
PipelineSecrets.assertBranch(branch);
|
|
56
|
+
const listed = await this.cloudApi.listSecrets(this.devProjectId).catch((error: unknown) => {
|
|
57
|
+
throw CloudApi.refusal(error, "the secrets are listed for a project member", {
|
|
58
|
+
unavailable: PipelineSecrets.#secretsUnavailable,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
const secrets = listed.filter((secret) => !branch || secret.scope === "*" || secret.scope === branch);
|
|
62
|
+
Logger.rawLog(PipelineSecrets.format(secrets));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async remove({ name, branch }: { name: string; branch: string | null }) {
|
|
66
|
+
PipelineSecrets.assertName(name);
|
|
67
|
+
PipelineSecrets.assertBranch(branch);
|
|
68
|
+
const removed = await this.cloudApi
|
|
69
|
+
.removeSecret(this.devProjectId, { scope: branch ?? "*", name })
|
|
70
|
+
.catch((error: unknown) => {
|
|
71
|
+
throw CloudApi.refusal(error, "a secret is removed by a project member signed in with `akan login`", {
|
|
72
|
+
unavailable: PipelineSecrets.#secretsUnavailable,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
Logger.rawLog(
|
|
76
|
+
removed
|
|
77
|
+
? `Removed ${name} for ${branch ? `branch ${branch}` : "every branch"}.`
|
|
78
|
+
: `No ${name} for ${branch ? `branch ${branch}` : "every branch"} to remove.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static format(secrets: PipelineSecretListing[]) {
|
|
83
|
+
if (!secrets.length) return "No pipeline secrets.";
|
|
84
|
+
const rows = [...secrets]
|
|
85
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.scope.localeCompare(b.scope))
|
|
86
|
+
.map((secret) =>
|
|
87
|
+
[secret.name, secret.scope === "*" ? "every branch" : secret.scope, secret.updatedAt, secret.updatedBy].map(
|
|
88
|
+
(cell) => String(cell ?? "-"),
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
const header = ["NAME", "SCOPE", "UPDATED", "BY"];
|
|
92
|
+
const widths = header.map((title, column) =>
|
|
93
|
+
Math.max(title.length, ...rows.map((row) => row[column]?.length ?? 0)),
|
|
94
|
+
);
|
|
95
|
+
return [header, ...rows]
|
|
96
|
+
.map((row) =>
|
|
97
|
+
row
|
|
98
|
+
.map((cell, column) => cell.padEnd(widths[column] ?? 0))
|
|
99
|
+
.join(" ")
|
|
100
|
+
.trimEnd(),
|
|
101
|
+
)
|
|
102
|
+
.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static assertName(name: string) {
|
|
106
|
+
const problem = PipelineNames.secretNameError(name);
|
|
107
|
+
if (problem) throw new Error(`${capitalize(problem)}.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
static assertBranch(branch: string | null) {
|
|
111
|
+
if (branch !== null && !PipelineSecrets.#secretBranches.includes(branch))
|
|
112
|
+
throw new Error(`--branch ${branch} is not a deployed branch: ${PipelineSecrets.#secretBranches.join(", ")}.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -13,14 +13,15 @@ interface Put {
|
|
|
13
13
|
body: Uint8Array;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
class
|
|
16
|
+
class FakeCloudHost {
|
|
17
17
|
readonly server: ReturnType<typeof Bun.serve>;
|
|
18
18
|
readonly requests: Record<string, unknown>[] = [];
|
|
19
19
|
readonly puts: Put[] = [];
|
|
20
20
|
readonly commits: string[] = [];
|
|
21
21
|
readonly registrations: Record<string, string>[] = [];
|
|
22
|
-
putStatuses: number[] = [];
|
|
23
|
-
|
|
22
|
+
putStatuses: (number | Response)[] = [];
|
|
23
|
+
stallPuts: boolean = false;
|
|
24
|
+
commitAnswers: (number | [number, string] | Response)[] = [];
|
|
24
25
|
release: Record<string, unknown> = { id: "release-1", status: "published", problems: [] };
|
|
25
26
|
requestRefusal: Response | null = null;
|
|
26
27
|
|
|
@@ -38,6 +39,7 @@ class FakeAkasys {
|
|
|
38
39
|
this.commits.length = 0;
|
|
39
40
|
this.registrations.length = 0;
|
|
40
41
|
this.putStatuses = [];
|
|
42
|
+
this.stallPuts = false;
|
|
41
43
|
this.commitAnswers = [];
|
|
42
44
|
this.requestRefusal = null;
|
|
43
45
|
}
|
|
@@ -61,7 +63,9 @@ class FakeAkasys {
|
|
|
61
63
|
headers: Object.fromEntries(request.headers),
|
|
62
64
|
body: new Uint8Array(await request.arrayBuffer()),
|
|
63
65
|
});
|
|
64
|
-
return new Response(
|
|
66
|
+
if (this.stallPuts) return await new Promise<Response>(() => undefined);
|
|
67
|
+
const answer = this.putStatuses.shift() ?? 200;
|
|
68
|
+
return answer instanceof Response ? answer : new Response(null, { status: answer });
|
|
65
69
|
}
|
|
66
70
|
if (url.pathname === "/api/commitUpdateUpload/project-1") {
|
|
67
71
|
const { uploadId } = (await request.json()) as { uploadId: string };
|
|
@@ -85,15 +89,16 @@ class FakeAkasys {
|
|
|
85
89
|
#answer() {
|
|
86
90
|
const answer = this.commitAnswers.shift();
|
|
87
91
|
if (!answer) return null;
|
|
92
|
+
if (answer instanceof Response) return answer;
|
|
88
93
|
const [status, key] = typeof answer === "number" ? [answer, "release.error.uploadCommitInProgress"] : answer;
|
|
89
94
|
return Response.json({ error: key }, { status });
|
|
90
95
|
}
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
const makeDir = tempDirs("akan-update-upload-");
|
|
94
|
-
const
|
|
99
|
+
const cloudHost = new FakeCloudHost();
|
|
95
100
|
const api = new CloudApi({} as Workspace, {
|
|
96
|
-
host:
|
|
101
|
+
host: cloudHost.host,
|
|
97
102
|
auth: { accessToken: { jwt: "jwt-1", refreshToken: null, expiresAt: null } },
|
|
98
103
|
});
|
|
99
104
|
const fields = { appName: "lu", target: "default", platform: "android" as const, channel: "develop", notes: null };
|
|
@@ -105,8 +110,8 @@ const archiveOf = async (bytes: string) => {
|
|
|
105
110
|
return archive;
|
|
106
111
|
};
|
|
107
112
|
|
|
108
|
-
beforeEach(() =>
|
|
109
|
-
afterAll(() =>
|
|
113
|
+
beforeEach(() => cloudHost.reset());
|
|
114
|
+
afterAll(() => cloudHost.server.stop(true));
|
|
110
115
|
|
|
111
116
|
describe("UpdatePackUpload", () => {
|
|
112
117
|
test("announces the archive's size and digest, PUTs it with nothing but its content type, then commits", async () => {
|
|
@@ -115,7 +120,7 @@ describe("UpdatePackUpload", () => {
|
|
|
115
120
|
const release = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
|
|
116
121
|
|
|
117
122
|
expect(release).toEqual({ id: "release-1", status: "published", problems: [], uploadId: "upload-1" });
|
|
118
|
-
expect(
|
|
123
|
+
expect(cloudHost.requests).toEqual([
|
|
119
124
|
{
|
|
120
125
|
...fields,
|
|
121
126
|
size: 10,
|
|
@@ -123,98 +128,129 @@ describe("UpdatePackUpload", () => {
|
|
|
123
128
|
authorization: "Bearer jwt-1",
|
|
124
129
|
},
|
|
125
130
|
]);
|
|
126
|
-
const [put] =
|
|
131
|
+
const [put] = cloudHost.puts;
|
|
127
132
|
expect(new TextDecoder().decode(put?.body)).toBe("pack bytes");
|
|
128
133
|
expect(put?.headers["content-type"]).toBe("application/gzip");
|
|
129
134
|
expect(put?.headers["content-length"]).toBe("10");
|
|
130
135
|
expect(put?.headers.authorization).toBeUndefined();
|
|
131
136
|
expect(Object.keys(put?.headers ?? {}).filter((name) => name.startsWith("x-amz-"))).toEqual([]);
|
|
132
|
-
expect(
|
|
137
|
+
expect(cloudHost.commits).toEqual(["upload-1"]);
|
|
133
138
|
});
|
|
134
139
|
|
|
135
140
|
test("takes one fresh ticket when storage answers 403, and gives up on the second without naming the URL", async () => {
|
|
136
141
|
const archive = await archiveOf("pack");
|
|
137
|
-
|
|
142
|
+
cloudHost.putStatuses = [403];
|
|
138
143
|
|
|
139
144
|
await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
|
|
140
|
-
expect(
|
|
141
|
-
expect(
|
|
145
|
+
expect(cloudHost.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-2"]);
|
|
146
|
+
expect(cloudHost.commits).toEqual(["upload-2"]);
|
|
142
147
|
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
cloudHost.reset();
|
|
149
|
+
cloudHost.putStatuses = [403, 403];
|
|
145
150
|
const refusal = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields).catch((e: Error) => e);
|
|
146
151
|
expect(String(refusal)).toContain("HTTP 403");
|
|
147
152
|
expect(String(refusal)).not.toContain("secret-signature");
|
|
148
|
-
expect(
|
|
153
|
+
expect(cloudHost.commits).toEqual([]);
|
|
149
154
|
});
|
|
150
155
|
|
|
151
156
|
test("retries a PUT the way curl --retry does, and stops at a refusal that is not transient", async () => {
|
|
152
157
|
const archive = await archiveOf("pack");
|
|
153
|
-
|
|
158
|
+
cloudHost.putStatuses = [503, 502];
|
|
154
159
|
await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
|
|
155
|
-
expect(
|
|
160
|
+
expect(cloudHost.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-1", "upload-1"]);
|
|
156
161
|
|
|
157
|
-
|
|
158
|
-
|
|
162
|
+
cloudHost.reset();
|
|
163
|
+
cloudHost.putStatuses = [400];
|
|
159
164
|
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("HTTP 400");
|
|
160
|
-
expect(
|
|
165
|
+
expect(cloudHost.puts).toHaveLength(1);
|
|
161
166
|
});
|
|
162
167
|
|
|
163
168
|
test("asks again with the same id while the commit is cut off or still running, but not past a real answer", async () => {
|
|
164
169
|
const archive = await archiveOf("pack");
|
|
165
|
-
|
|
170
|
+
cloudHost.commitAnswers = [524, 409, 502];
|
|
166
171
|
await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
|
|
167
|
-
expect(
|
|
172
|
+
expect(cloudHost.commits).toEqual(["upload-1", "upload-1", "upload-1", "upload-1"]);
|
|
168
173
|
|
|
169
|
-
|
|
170
|
-
|
|
174
|
+
cloudHost.reset();
|
|
175
|
+
cloudHost.commitAnswers = [404];
|
|
171
176
|
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("404");
|
|
172
|
-
expect(
|
|
177
|
+
expect(cloudHost.commits).toEqual(["upload-1"]);
|
|
173
178
|
|
|
174
|
-
|
|
175
|
-
|
|
179
|
+
cloudHost.reset();
|
|
180
|
+
cloudHost.commitAnswers = Array.from({ length: 100 }, () => 524);
|
|
176
181
|
const cutOff = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 35, commitLimitMs: 10_000 };
|
|
177
182
|
await expect(new UpdatePackUpload(api, "project-1", cutOff).send(archive, fields)).rejects.toThrow("524");
|
|
178
|
-
expect(
|
|
183
|
+
expect(cloudHost.commits.length).toBeLessThan(6);
|
|
179
184
|
});
|
|
180
185
|
|
|
181
186
|
test("keeps waiting on a live claim past the cut-off window, up to the overall limit", async () => {
|
|
182
187
|
const archive = await archiveOf("pack");
|
|
183
|
-
|
|
188
|
+
cloudHost.commitAnswers = [...Array.from({ length: 6 }, () => 409), 524, ...Array.from({ length: 6 }, () => 409)];
|
|
184
189
|
const timing = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 25, commitLimitMs: 10_000 };
|
|
185
190
|
await new UpdatePackUpload(api, "project-1", timing).send(archive, fields);
|
|
186
|
-
expect(
|
|
191
|
+
expect(cloudHost.commits).toHaveLength(14);
|
|
187
192
|
|
|
188
|
-
|
|
189
|
-
|
|
193
|
+
cloudHost.reset();
|
|
194
|
+
cloudHost.commitAnswers = Array.from({ length: 100 }, () => 409);
|
|
190
195
|
const limited = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 10_000, commitLimitMs: 35 };
|
|
191
196
|
await expect(new UpdatePackUpload(api, "project-1", limited).send(archive, fields)).rejects.toThrow("409");
|
|
192
|
-
expect(
|
|
197
|
+
expect(cloudHost.commits.length).toBeLessThan(6);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("gives up a stalled PUT after a time its size sets, and tries it again as a dropped one", async () => {
|
|
201
|
+
const archive = await archiveOf("pack");
|
|
202
|
+
cloudHost.stallPuts = true;
|
|
203
|
+
|
|
204
|
+
await expect(
|
|
205
|
+
new UpdatePackUpload(api, "project-1", { ...quick, putTimeoutMs: 50 }).send(archive, fields),
|
|
206
|
+
).rejects.toThrow("no response");
|
|
207
|
+
expect(cloudHost.puts).toHaveLength(4);
|
|
208
|
+
expect(cloudHost.commits).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("asks again past a gateway's 503, but not past one the server answers with its own refusal", async () => {
|
|
212
|
+
const archive = await archiveOf("pack");
|
|
213
|
+
cloudHost.commitAnswers = [
|
|
214
|
+
new Response("No healthy federation child is ready", { status: 503 }),
|
|
215
|
+
[503, "base.error.serverUnavailable"],
|
|
216
|
+
];
|
|
217
|
+
await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
|
|
218
|
+
expect(cloudHost.commits).toEqual(["upload-1", "upload-1", "upload-1"]);
|
|
219
|
+
|
|
220
|
+
cloudHost.reset();
|
|
221
|
+
cloudHost.commitAnswers = [[503, "release.error.signingUnavailable"]];
|
|
222
|
+
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow(
|
|
223
|
+
"release.error.signingUnavailable",
|
|
224
|
+
);
|
|
225
|
+
expect(cloudHost.commits).toEqual(["upload-1"]);
|
|
193
226
|
});
|
|
194
227
|
|
|
195
228
|
test("tells a server without staged uploads apart from one whose 404 is a refusal", async () => {
|
|
196
229
|
const archive = await archiveOf("pack");
|
|
197
|
-
|
|
230
|
+
cloudHost.requestRefusal = new Response("Not Found", { status: 404 });
|
|
198
231
|
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow(
|
|
199
232
|
"does not take direct pack uploads yet",
|
|
200
233
|
);
|
|
201
234
|
|
|
202
|
-
|
|
235
|
+
cloudHost.requestRefusal = Response.json(
|
|
236
|
+
{ error: "devProject.error.appNotFound", statusCode: 404 },
|
|
237
|
+
{ status: 404 },
|
|
238
|
+
);
|
|
203
239
|
const refusal = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields).catch((e: Error) => e);
|
|
204
240
|
expect(String(refusal)).toContain("devProject.error.appNotFound");
|
|
205
|
-
expect(
|
|
241
|
+
expect(cloudHost.puts).toEqual([]);
|
|
206
242
|
});
|
|
207
243
|
|
|
208
244
|
test("stops at once on a 409 that is not a commit in progress: the upload is already used", async () => {
|
|
209
245
|
const archive = await archiveOf("pack");
|
|
210
|
-
|
|
246
|
+
cloudHost.commitAnswers = [[409, "release.error.uploadEmbedded"]];
|
|
211
247
|
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("uploadEmbedded");
|
|
212
|
-
expect(
|
|
248
|
+
expect(cloudHost.commits).toHaveLength(1);
|
|
213
249
|
});
|
|
214
250
|
|
|
215
251
|
test("stages a binary's pack and registers it by upload id, asking again while the server is still at it", async () => {
|
|
216
252
|
const archive = await archiveOf("pack");
|
|
217
|
-
|
|
253
|
+
cloudHost.commitAnswers = [524, 409];
|
|
218
254
|
const upload = new UpdatePackUpload(api, "project-1", quick);
|
|
219
255
|
const binary = await upload.stage(archive, fields).then((packUploadId) =>
|
|
220
256
|
upload.settle(() =>
|
|
@@ -230,9 +266,9 @@ describe("UpdatePackUpload", () => {
|
|
|
230
266
|
);
|
|
231
267
|
|
|
232
268
|
expect(binary).toEqual({ id: "binary-1" });
|
|
233
|
-
expect(
|
|
234
|
-
expect(
|
|
235
|
-
expect(
|
|
269
|
+
expect(cloudHost.commits).toEqual([]);
|
|
270
|
+
expect(cloudHost.registrations).toHaveLength(3);
|
|
271
|
+
expect(cloudHost.registrations[2]).toEqual({
|
|
236
272
|
appName: "lu",
|
|
237
273
|
target: "default",
|
|
238
274
|
distribution: "playTesting",
|
|
@@ -242,9 +278,28 @@ describe("UpdatePackUpload", () => {
|
|
|
242
278
|
});
|
|
243
279
|
});
|
|
244
280
|
|
|
281
|
+
test("waits as long as storage's Retry-After asks before trying a PUT again", async () => {
|
|
282
|
+
const archive = await archiveOf("pack");
|
|
283
|
+
cloudHost.putStatuses = [new Response(null, { status: 429, headers: { "Retry-After": "1" } })];
|
|
284
|
+
const startedAt = performance.now();
|
|
285
|
+
|
|
286
|
+
await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
|
|
287
|
+
|
|
288
|
+
expect(performance.now() - startedAt).toBeGreaterThanOrEqual(950);
|
|
289
|
+
expect(cloudHost.puts).toHaveLength(2);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("never asks again sooner than the server's Retry-After, nor past the limit it would wait to", async () => {
|
|
293
|
+
const archive = await archiveOf("pack");
|
|
294
|
+
cloudHost.commitAnswers = [new Response("busy", { status: 503, headers: { "Retry-After": "120" } })];
|
|
295
|
+
|
|
296
|
+
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("503");
|
|
297
|
+
expect(cloudHost.commits).toEqual(["upload-1"]);
|
|
298
|
+
});
|
|
299
|
+
|
|
245
300
|
test("refuses an empty archive before asking for a ticket", async () => {
|
|
246
301
|
const archive = await archiveOf("");
|
|
247
302
|
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("0 bytes");
|
|
248
|
-
expect(
|
|
303
|
+
expect(cloudHost.requests).toEqual([]);
|
|
249
304
|
});
|
|
250
305
|
});
|