@akanjs/devkit 3.0.0-beta.2 → 3.0.0-beta.20
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/README.md +1 -0
- package/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +6 -8
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +1 -3
- package/akanApp/akanApp.host.ts +82 -254
- package/akanApp/devHostPolicy.test.ts +10 -34
- package/akanApp/devHostPolicy.ts +12 -98
- package/akanConfig/akanConfig.test.ts +85 -69
- package/akanConfig/akanConfig.ts +69 -153
- package/akanConfig/types.ts +1 -0
- package/akanContext.ts +106 -159
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +54 -61
- package/applicationReleasePackager.ts +24 -44
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +9 -30
- package/artifact/implicitRootLayout.ts +34 -106
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +6 -1
- package/biomeBase.ts +2 -5
- package/capacitorApp.test.ts +32 -16
- package/capacitorApp.ts +110 -238
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +77 -39
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +26 -5
- package/cloud/globalConfig.ts +70 -32
- package/codeAgent/agent/AkanCodeServices.ts +97 -0
- package/codeAgent/agent/AkanContextFiles.ts +94 -0
- package/codeAgent/agent/CodeAgent.ts +551 -0
- package/codeAgent/agent/CodeAgentAsks.ts +66 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
- package/codeAgent/agent/CodeAgentGate.ts +93 -0
- package/codeAgent/agent/CodeAgentProxy.ts +82 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
- package/codeAgent/agent/CodeAgentUi.ts +47 -0
- package/codeAgent/agent/CodeMailbox.ts +148 -0
- package/codeAgent/agent/CodeSessionFork.ts +56 -0
- package/codeAgent/agent/CodeSessionIndex.ts +78 -0
- package/codeAgent/agent/SubagentPool.ts +214 -0
- package/codeAgent/agent/akanCodeModel.ts +47 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanContextFiles.test.ts +114 -0
- package/codeAgent/agent/akanSystemPrompt.ts +46 -0
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +523 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/agent/codeMailbox.test.ts +76 -0
- package/codeAgent/agent/codeSessionFork.test.ts +58 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
- package/codeAgent/agent/consoleToStderr.ts +8 -0
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +82 -0
- package/codeAgent/feedback/PreviewChrome.ts +82 -0
- package/codeAgent/feedback/PreviewFeedback.ts +46 -0
- package/codeAgent/feedback/PreviewView.ts +95 -0
- package/codeAgent/feedback/TurnFeedback.ts +98 -0
- package/codeAgent/feedback/VerifyFeedback.ts +40 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +34 -0
- package/codeAgent/skills/akan-code/SKILL.md +130 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +67 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +50 -0
- package/codeAgent/tools/AkanCodePlugins.ts +136 -0
- package/codeAgent/tools/AkanEditScope.ts +48 -0
- package/codeAgent/tools/AkanEnvKeys.ts +50 -0
- package/codeAgent/tools/AkanToolPack.ts +92 -0
- package/codeAgent/tools/AkanVerifier.ts +48 -0
- package/codeAgent/tools/AskToolPack.ts +99 -0
- package/codeAgent/tools/MailToolPack.ts +76 -0
- package/codeAgent/tools/McpClient.ts +187 -0
- package/codeAgent/tools/McpOAuth.ts +233 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
- package/codeAgent/tools/McpServerConfig.ts +129 -0
- package/codeAgent/tools/McpSignIn.ts +158 -0
- package/codeAgent/tools/McpTokenStore.ts +81 -0
- package/codeAgent/tools/McpToolPack.ts +199 -0
- package/codeAgent/tools/SessionToolPack.ts +91 -0
- package/codeAgent/tools/WebToolPack.ts +147 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
- package/codeAgent/tools/askToolPack.test.ts +116 -0
- package/codeAgent/tools/mcpOAuth.test.ts +612 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +46 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +2 -10
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +68 -145
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +31 -80
- package/devkitUtils.test.ts +48 -20
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +132 -106
- package/executors.ts +194 -484
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +5 -3
- package/fileSys.ts +6 -8
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +27 -38
- package/frontendBuild/autoImportSync.ts +31 -65
- package/frontendBuild/buildRouteClient.test.ts +62 -37
- package/frontendBuild/bundleDefine.ts +12 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.ts +6 -41
- package/frontendBuild/csrArtifactBuilder.ts +6 -25
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +29 -78
- package/frontendBuild/cssImportResolver.ts +6 -19
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/fontOptimizer.test.ts +6 -14
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +46 -69
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +8 -32
- package/frontendBuild/hmrWatcher.ts +14 -71
- package/frontendBuild/pagesBundleBuilder.ts +4 -29
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/routeClientBuilder.ts +23 -46
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +22 -57
- package/incrementalBuilder/buildBatchProtocol.ts +8 -36
- package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
- package/incrementalBuilder/buildBatchRunner.ts +10 -31
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/devWatchBatch.test.ts +21 -24
- package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
- package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +49 -161
- package/index.ts +5 -17
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +47 -194
- package/integration/devStabilityHarness.test.ts +13 -28
- package/integration/devStabilityHarness.ts +65 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/gritRules.test.ts +8 -24
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/linter.test.ts +1 -2
- package/linter.ts +26 -116
- package/mobile/mobileTarget.ts +14 -13
- package/package.json +10 -5
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +46 -0
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +56 -0
- package/platformTest/IdleWatchedProcess.ts +73 -0
- package/platformTest/LinuxDockerTarget.ts +105 -0
- package/platformTest/PlatformTestRun.test.ts +64 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +142 -0
- package/platformTest/TestSnapshot.test.ts +72 -0
- package/platformTest/TestSnapshot.ts +103 -0
- package/platformTest/WindowsSshTarget.ts +151 -0
- package/prompter.ts +3 -13
- package/qualityScanner.test.ts +9 -26
- package/qualityScanner.ts +197 -285
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +7 -42
- package/scanInfo.ts +87 -216
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/stripAnsi.ts +3 -0
- package/subspace.test.ts +30 -25
- package/subspace.ts +81 -115
- package/subspaceConfig.ts +11 -9
- package/testHelpers.ts +194 -0
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +35 -20
- package/transforms/transforms.test.ts +153 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +2 -22
- package/typeChecker.ts +7 -63
- package/types.ts +1 -0
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +101 -110
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +52 -69
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.ts +4 -19
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/guideline.ts +0 -19
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- package/workflow/primitive.ts +0 -59
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs } from "../testHelpers";
|
|
5
|
+
import { IdleWatchedProcess } from "./IdleWatchedProcess";
|
|
6
|
+
|
|
7
|
+
const makeRoot = tempDirs("akan-idle-watch-");
|
|
8
|
+
const makeLogPath = async () => path.join(await makeRoot(), "nested", "run.log");
|
|
9
|
+
|
|
10
|
+
describe("IdleWatchedProcess", () => {
|
|
11
|
+
test("tees the output of a command that finishes into the log and reports its exit code", async () => {
|
|
12
|
+
const logPath = await makeLogPath();
|
|
13
|
+
const result = await new IdleWatchedProcess(
|
|
14
|
+
[process.execPath, "--eval", "console.log('out'); console.error('err'); process.exit(3)"],
|
|
15
|
+
{ logPath, idleMs: 10_000 },
|
|
16
|
+
).run();
|
|
17
|
+
|
|
18
|
+
expect(result.exitCode).toBe(3);
|
|
19
|
+
expect(result.hung).toBe(false);
|
|
20
|
+
expect(result.output).toContain("out");
|
|
21
|
+
expect(result.output).toContain("err");
|
|
22
|
+
expect(await readFile(logPath, "utf8")).toContain("out");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("gives up on a command that goes quiet, after asking the caller to clean up", async () => {
|
|
26
|
+
const logPath = await makeLogPath();
|
|
27
|
+
let cleanedUp = false;
|
|
28
|
+
const result = await new IdleWatchedProcess(
|
|
29
|
+
[process.execPath, "--eval", "console.log('started'); setInterval(() => {}, 1000)"],
|
|
30
|
+
{
|
|
31
|
+
logPath,
|
|
32
|
+
idleMs: 400,
|
|
33
|
+
onIdle: async () => {
|
|
34
|
+
cleanedUp = true;
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
).run();
|
|
38
|
+
|
|
39
|
+
expect(result.hung).toBe(true);
|
|
40
|
+
expect(result.exitCode).toBeNull();
|
|
41
|
+
expect(cleanedUp).toBe(true);
|
|
42
|
+
expect(result.output).toContain("started");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a command that keeps talking is not idle, but still stops at the total budget", async () => {
|
|
46
|
+
const logPath = await makeLogPath();
|
|
47
|
+
const result = await new IdleWatchedProcess(
|
|
48
|
+
[process.execPath, "--eval", "setInterval(() => console.log('tick'), 50)"],
|
|
49
|
+
{ logPath, idleMs: 5_000, totalMs: 500 },
|
|
50
|
+
).run();
|
|
51
|
+
|
|
52
|
+
expect(result.hung).toBe(false);
|
|
53
|
+
expect(result.timedOut).toBe(true);
|
|
54
|
+
expect(result.exitCode).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface IdleWatchedResult {
|
|
5
|
+
exitCode: number | null;
|
|
6
|
+
hung: boolean;
|
|
7
|
+
timedOut: boolean;
|
|
8
|
+
output: string;
|
|
9
|
+
ms: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface IdleWatchedOptions {
|
|
13
|
+
logPath: string;
|
|
14
|
+
idleMs: number;
|
|
15
|
+
totalMs?: number;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
env?: Record<string, string | undefined>;
|
|
18
|
+
onIdle?: () => Promise<void>;
|
|
19
|
+
onOutput?: (text: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Silence is the only reliable hang signal: a runner stuck in a synchronous loop never lets bun's test timeout fire.
|
|
23
|
+
export class IdleWatchedProcess {
|
|
24
|
+
readonly #command: string[];
|
|
25
|
+
readonly #options: IdleWatchedOptions;
|
|
26
|
+
|
|
27
|
+
constructor(command: string[], options: IdleWatchedOptions) {
|
|
28
|
+
this.#command = command;
|
|
29
|
+
this.#options = options;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async run(): Promise<IdleWatchedResult> {
|
|
33
|
+
const { logPath, idleMs, totalMs, cwd, env, onIdle, onOutput } = this.#options;
|
|
34
|
+
await mkdir(path.dirname(logPath), { recursive: true });
|
|
35
|
+
const log = Bun.file(logPath).writer();
|
|
36
|
+
const startedAt = performance.now();
|
|
37
|
+
let lastOutputAt = startedAt;
|
|
38
|
+
let output = "";
|
|
39
|
+
let hung = false;
|
|
40
|
+
let timedOut = false;
|
|
41
|
+
const proc = Bun.spawn(this.#command, { cwd, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
42
|
+
const pump = async (stream: ReadableStream<Uint8Array>) => {
|
|
43
|
+
const decoder = new TextDecoder();
|
|
44
|
+
for await (const chunk of stream) {
|
|
45
|
+
lastOutputAt = performance.now();
|
|
46
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
47
|
+
output += text;
|
|
48
|
+
log.write(chunk);
|
|
49
|
+
onOutput?.(text);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const pumping = Promise.all([pump(proc.stdout), pump(proc.stderr)]);
|
|
53
|
+
const watch = setInterval(
|
|
54
|
+
() => {
|
|
55
|
+
const now = performance.now();
|
|
56
|
+
if (now - lastOutputAt >= idleMs) hung = true;
|
|
57
|
+
else if (totalMs !== undefined && now - startedAt >= totalMs) timedOut = true;
|
|
58
|
+
else return;
|
|
59
|
+
clearInterval(watch);
|
|
60
|
+
void (async () => {
|
|
61
|
+
await onIdle?.().catch(() => undefined);
|
|
62
|
+
proc.kill();
|
|
63
|
+
})();
|
|
64
|
+
},
|
|
65
|
+
Math.max(50, Math.min(1000, idleMs / 4)),
|
|
66
|
+
);
|
|
67
|
+
const exitCode = await proc.exited;
|
|
68
|
+
clearInterval(watch);
|
|
69
|
+
await pumping;
|
|
70
|
+
await log.end();
|
|
71
|
+
return { exitCode: hung || timedOut ? null : exitCode, hung, timedOut, output, ms: performance.now() - startedAt };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { LinuxTestTargetConfig, PlatformGatePolicy } from "../cloud/constants";
|
|
4
|
+
import { IdleWatchedProcess } from "./IdleWatchedProcess";
|
|
5
|
+
import { type PlatformRunContext, PlatformTestTarget } from "./PlatformTestTarget";
|
|
6
|
+
|
|
7
|
+
export class LinuxDockerTarget extends PlatformTestTarget {
|
|
8
|
+
static readonly cacheVolume = "akan-test-bun-cache";
|
|
9
|
+
|
|
10
|
+
readonly platform = "linux";
|
|
11
|
+
readonly policy: PlatformGatePolicy;
|
|
12
|
+
readonly #config: LinuxTestTargetConfig;
|
|
13
|
+
#container: string | null = null;
|
|
14
|
+
|
|
15
|
+
constructor(config: LinuxTestTargetConfig = {}) {
|
|
16
|
+
super();
|
|
17
|
+
this.#config = config;
|
|
18
|
+
this.policy = config.policy ?? "gate";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
//* No compiler in the image: with one, ssh2 builds its native addon and Bun panics loading it (oven-sh/bun#18546).
|
|
22
|
+
static readonly dockerfile = [
|
|
23
|
+
`FROM oven/bun:${Bun.version}-slim`,
|
|
24
|
+
"RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates procps lsof && rm -rf /var/lib/apt/lists/*",
|
|
25
|
+
].join("\n");
|
|
26
|
+
|
|
27
|
+
get image() {
|
|
28
|
+
return `akan-test-linux:bun-${Bun.version}-${Bun.hash(LinuxDockerTarget.dockerfile).toString(16).slice(0, 8)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
protected async prepare(context: PlatformRunContext) {
|
|
32
|
+
if ((await LinuxDockerTarget.spawnText(["docker", "info"])).exitCode !== 0)
|
|
33
|
+
throw new Error("Docker is not running — start Docker Desktop, or leave linux out of --platforms");
|
|
34
|
+
await this.#ensureImage();
|
|
35
|
+
const container = `akan-test-${context.runId}`;
|
|
36
|
+
const cpus = this.#config.cpus ?? Math.max(2, Math.floor(os.availableParallelism() / 2));
|
|
37
|
+
const started = await LinuxDockerTarget.spawnText([
|
|
38
|
+
"docker",
|
|
39
|
+
"run",
|
|
40
|
+
"-d",
|
|
41
|
+
"--rm",
|
|
42
|
+
"--name",
|
|
43
|
+
container,
|
|
44
|
+
"--cpus",
|
|
45
|
+
String(cpus),
|
|
46
|
+
...(this.#config.memory ? ["--memory", this.#config.memory] : []),
|
|
47
|
+
"-v",
|
|
48
|
+
`${LinuxDockerTarget.cacheVolume}:/root/.bun/install/cache`,
|
|
49
|
+
"-v",
|
|
50
|
+
`${path.dirname(context.snapshot.tarPath)}:/io:ro`,
|
|
51
|
+
"-v",
|
|
52
|
+
`${context.envPath}:/io-env:ro`,
|
|
53
|
+
"-w",
|
|
54
|
+
"/w",
|
|
55
|
+
this.image,
|
|
56
|
+
"sleep",
|
|
57
|
+
"infinity",
|
|
58
|
+
]);
|
|
59
|
+
if (started.exitCode !== 0) throw new Error(`docker run failed: ${started.stderr.trim()}`);
|
|
60
|
+
this.#container = container;
|
|
61
|
+
const extract = await this.exec(
|
|
62
|
+
`tar -xf /io/${path.basename(context.snapshot.tarPath)} -C /w && cp /io-env /w/.env && git -C /w init -q`,
|
|
63
|
+
path.join(context.logDir, this.platform, "extract.log"),
|
|
64
|
+
PlatformTestTarget.installIdleMs,
|
|
65
|
+
);
|
|
66
|
+
if (extract.exitCode !== 0) throw new Error("extracting the snapshot into the container failed");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected async exec(command: string, logPath: string, idleMs: number) {
|
|
70
|
+
const container = this.#container;
|
|
71
|
+
if (!container) throw new Error("the linux container is not running");
|
|
72
|
+
return await new IdleWatchedProcess(
|
|
73
|
+
[
|
|
74
|
+
"docker",
|
|
75
|
+
"exec",
|
|
76
|
+
...Object.entries(PlatformTestTarget.gitIdentityEnv).flatMap(([key, value]) => ["-e", `${key}=${value}`]),
|
|
77
|
+
container,
|
|
78
|
+
"sh",
|
|
79
|
+
"-c",
|
|
80
|
+
command,
|
|
81
|
+
],
|
|
82
|
+
{
|
|
83
|
+
logPath,
|
|
84
|
+
idleMs,
|
|
85
|
+
onIdle: async () => {
|
|
86
|
+
await LinuxDockerTarget.spawnText(["docker", "exec", container, "pkill", "-9", "bun"]);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
).run();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
protected async cleanup() {
|
|
93
|
+
if (this.#container) await LinuxDockerTarget.spawnText(["docker", "rm", "-f", this.#container]);
|
|
94
|
+
this.#container = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async #ensureImage() {
|
|
98
|
+
if ((await LinuxDockerTarget.spawnText(["docker", "image", "inspect", this.image])).exitCode === 0) return;
|
|
99
|
+
const built = await LinuxDockerTarget.spawnText(
|
|
100
|
+
["docker", "build", "-t", this.image, "-"],
|
|
101
|
+
LinuxDockerTarget.dockerfile,
|
|
102
|
+
);
|
|
103
|
+
if (built.exitCode !== 0) throw new Error(`building ${this.image} failed: ${built.stderr.trim().slice(-400)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs } from "../testHelpers";
|
|
5
|
+
import { BunTestReport } from "./BunTestReport";
|
|
6
|
+
import { PlatformTestRun } from "./PlatformTestRun";
|
|
7
|
+
import type { PackageTestResult, PlatformTestResult } from "./PlatformTestTarget";
|
|
8
|
+
|
|
9
|
+
const makeRoot = tempDirs("akan-platform-env-");
|
|
10
|
+
|
|
11
|
+
const pkg = (status: PackageTestResult["status"]): PackageTestResult => ({
|
|
12
|
+
pkg: "akanjs",
|
|
13
|
+
status,
|
|
14
|
+
report: new BunTestReport(""),
|
|
15
|
+
ms: 1,
|
|
16
|
+
logPath: "x.log",
|
|
17
|
+
});
|
|
18
|
+
const result = (
|
|
19
|
+
platform: PlatformTestResult["platform"],
|
|
20
|
+
policy: PlatformTestResult["policy"],
|
|
21
|
+
packages: PackageTestResult[],
|
|
22
|
+
infraError: string | null = null,
|
|
23
|
+
): PlatformTestResult => ({ platform, policy, packages, envWarnings: [], infraError, ms: 1 });
|
|
24
|
+
|
|
25
|
+
describe("PlatformTestRun", () => {
|
|
26
|
+
test("a failing gate blocks, a failing warn only warns, and a setup failure counts as a failure", () => {
|
|
27
|
+
const verdict = PlatformTestRun.verdict([
|
|
28
|
+
result("linux", "gate", [pkg("pass"), pkg("fail")]),
|
|
29
|
+
result("windows", "warn", [pkg("pass")], "cannot reach user@host over ssh"),
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
expect(verdict.blocking.map((one) => one.platform)).toEqual(["linux"]);
|
|
33
|
+
expect(verdict.warning.map((one) => one.platform)).toEqual(["windows"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a hung package fails its platform", () => {
|
|
37
|
+
expect(PlatformTestRun.failed(result("windows", "gate", [pkg("pass"), pkg("hung")]))).toBe(true);
|
|
38
|
+
expect(PlatformTestRun.failed(result("windows", "gate", [pkg("pass")]))).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("hands a test host only the env keys the CLI needs, never the secrets beside them", async () => {
|
|
42
|
+
const root = await makeRoot();
|
|
43
|
+
await writeFile(
|
|
44
|
+
path.join(root, ".env"),
|
|
45
|
+
["USE_AKANJS_PKGS=true", "OPENAI_API_KEY=sk-secret", 'AKAN_PUBLIC_SERVE_DOMAIN="akanjs.com"', "JEV_KEY=x"].join(
|
|
46
|
+
"\n",
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(await PlatformTestRun.allowlistedEnv(root)).toBe(
|
|
51
|
+
'USE_AKANJS_PKGS=true\nAKAN_PUBLIC_SERVE_DOMAIN="akanjs.com"\n',
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("reads a comma-separated platform list, where none means no remote platform", () => {
|
|
56
|
+
expect(PlatformTestRun.parsePlatforms("linux, Windows,linux")).toEqual(["linux", "windows"]);
|
|
57
|
+
expect(PlatformTestRun.parsePlatforms("none")).toEqual([]);
|
|
58
|
+
expect(() => PlatformTestRun.parsePlatforms("linux,mac")).toThrow("mac");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("run ids sort by time", () => {
|
|
62
|
+
expect(PlatformTestRun.newRunId(new Date(2026, 8, 26, 9, 5, 7))).toBe("20260926-090507");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import type { TestTargetsConfig } from "../cloud/constants";
|
|
4
|
+
import { GlobalConfig } from "../cloud/globalConfig";
|
|
5
|
+
import { LinuxDockerTarget } from "./LinuxDockerTarget";
|
|
6
|
+
import { type PlatformTestResult, PlatformTestTarget, type RemoteTestPlatform } from "./PlatformTestTarget";
|
|
7
|
+
import { TestSnapshot } from "./TestSnapshot";
|
|
8
|
+
import { WindowsSshTarget } from "./WindowsSshTarget";
|
|
9
|
+
|
|
10
|
+
interface PlatformTestRunOptions {
|
|
11
|
+
workspaceRoot: string;
|
|
12
|
+
platforms: RemoteTestPlatform[];
|
|
13
|
+
pkgs: string[];
|
|
14
|
+
targets: TestTargetsConfig;
|
|
15
|
+
onProgress: (message: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PlatformVerdict {
|
|
19
|
+
blocking: PlatformTestResult[];
|
|
20
|
+
warning: PlatformTestResult[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class PlatformTestRun {
|
|
24
|
+
//? The CLI refuses a root without these, and the cli suite builds temp workspaces from them; none is a secret.
|
|
25
|
+
static readonly envKeys = [
|
|
26
|
+
"USE_AKANJS_PKGS",
|
|
27
|
+
"AKAN_PUBLIC_REPO_NAME",
|
|
28
|
+
"AKAN_PUBLIC_SERVE_DOMAIN",
|
|
29
|
+
"AKAN_PUBLIC_ENV",
|
|
30
|
+
"AKAN_PUBLIC_OPERATION_MODE",
|
|
31
|
+
"APP_OPERATION_MODE",
|
|
32
|
+
"SERVER_MODE",
|
|
33
|
+
] as const;
|
|
34
|
+
static readonly promoteAfterGreenRuns = 3;
|
|
35
|
+
|
|
36
|
+
readonly runId: string;
|
|
37
|
+
readonly logDir: string;
|
|
38
|
+
readonly snapshot: TestSnapshot;
|
|
39
|
+
readonly #results: Promise<PlatformTestResult[]>;
|
|
40
|
+
|
|
41
|
+
constructor(runId: string, logDir: string, snapshot: TestSnapshot, results: Promise<PlatformTestResult[]>) {
|
|
42
|
+
this.runId = runId;
|
|
43
|
+
this.logDir = logDir;
|
|
44
|
+
this.snapshot = snapshot;
|
|
45
|
+
this.#results = results;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static async start({ workspaceRoot, platforms, pkgs, targets, onProgress }: PlatformTestRunOptions) {
|
|
49
|
+
const runId = PlatformTestRun.newRunId();
|
|
50
|
+
const logDir = path.join(workspaceRoot, "local", "deploy-test", runId);
|
|
51
|
+
const snapshot = await TestSnapshot.create(workspaceRoot, logDir);
|
|
52
|
+
const envPath = path.join(logDir, "env");
|
|
53
|
+
await Bun.write(envPath, await PlatformTestRun.allowlistedEnv(workspaceRoot));
|
|
54
|
+
const instances = platforms.map((platform) => {
|
|
55
|
+
if (platform === "linux") return new LinuxDockerTarget(targets.linux);
|
|
56
|
+
if (!targets.windows) throw new Error("the windows test target is not configured");
|
|
57
|
+
return new WindowsSshTarget(targets.windows);
|
|
58
|
+
});
|
|
59
|
+
const results = Promise.all(
|
|
60
|
+
instances.map((target) => target.run({ snapshot, envPath, runId, logDir, pkgs, onProgress })),
|
|
61
|
+
);
|
|
62
|
+
return new PlatformTestRun(runId, logDir, snapshot, results);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async results() {
|
|
66
|
+
return await this.#results;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static parsePlatforms(value: string): RemoteTestPlatform[] {
|
|
70
|
+
const names = value
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((name) => name.trim().toLowerCase())
|
|
73
|
+
.filter((name) => name && name !== "none");
|
|
74
|
+
const unknown = names.filter((name) => name !== "linux" && name !== "windows");
|
|
75
|
+
if (unknown.length)
|
|
76
|
+
throw new Error(`Unknown test platform: ${unknown.join(", ")} (expected linux, windows, or none)`);
|
|
77
|
+
return [...new Set(names as RemoteTestPlatform[])];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
static newRunId(now = new Date()) {
|
|
81
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
82
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static async allowlistedEnv(workspaceRoot: string) {
|
|
86
|
+
const envFile = Bun.file(path.join(workspaceRoot, ".env"));
|
|
87
|
+
const lines = (await envFile.exists()) ? (await envFile.text()).split(/\r?\n/) : [];
|
|
88
|
+
const allowed = new Set<string>(PlatformTestRun.envKeys);
|
|
89
|
+
const kept = lines.filter((line) => allowed.has(line.split("=", 1)[0]?.trim() ?? ""));
|
|
90
|
+
return `${kept.join("\n")}\n`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
static failed(result: PlatformTestResult) {
|
|
94
|
+
return !!result.infraError || result.packages.some((pkg) => pkg.status !== "pass");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static verdict(results: PlatformTestResult[]): PlatformVerdict {
|
|
98
|
+
const failed = results.filter((result) => PlatformTestRun.failed(result));
|
|
99
|
+
return {
|
|
100
|
+
blocking: failed.filter((result) => result.policy === "gate"),
|
|
101
|
+
warning: failed.filter((result) => result.policy === "warn"),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
format(results: PlatformTestResult[], { maxFailures = 15 }: { maxFailures?: number } = {}) {
|
|
106
|
+
const lines = [
|
|
107
|
+
chalk.bold(
|
|
108
|
+
`Platform tests · snapshot ${this.snapshot.digest} (${this.snapshot.fileCount} files) · ${path.relative(this.snapshot.root, this.logDir)}`,
|
|
109
|
+
),
|
|
110
|
+
];
|
|
111
|
+
for (const result of results) {
|
|
112
|
+
const mark = PlatformTestRun.failed(result) ? chalk.red("✖") : chalk.green("✔");
|
|
113
|
+
lines.push(`${mark} ${result.platform} (${result.policy}, ${Math.round(result.ms / 1000)}s)`);
|
|
114
|
+
if (result.infraError) lines.push(chalk.red(` setup: ${result.infraError}`));
|
|
115
|
+
for (const warning of result.envWarnings) lines.push(chalk.yellow(` env: ${warning}`));
|
|
116
|
+
for (const pkg of result.packages) {
|
|
117
|
+
const line = ` ${PlatformTestTarget.describe(pkg)}`;
|
|
118
|
+
lines.push(pkg.status === "pass" ? line : chalk.red(line));
|
|
119
|
+
for (const failure of pkg.report.failures.slice(0, maxFailures))
|
|
120
|
+
lines.push(chalk.dim(` ${failure.file ? `${failure.file} › ` : ""}${failure.name}`));
|
|
121
|
+
if (pkg.report.failures.length > maxFailures)
|
|
122
|
+
lines.push(chalk.dim(` … ${pkg.report.failures.length - maxFailures} more in ${pkg.logPath}`));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A `warn` platform becomes a gate once it has been green for enough deploys in a row. */
|
|
129
|
+
static async recordStreaks(results: PlatformTestResult[]) {
|
|
130
|
+
const targets = await GlobalConfig.getTestTargets();
|
|
131
|
+
const windows = targets.windows;
|
|
132
|
+
const result = results.find((one) => one.platform === "windows");
|
|
133
|
+
if (!windows || !result || (windows.policy ?? "warn") !== "warn") return null;
|
|
134
|
+
const greenStreak = PlatformTestRun.failed(result) ? 0 : (windows.greenStreak ?? 0) + 1;
|
|
135
|
+
const promoted = greenStreak >= PlatformTestRun.promoteAfterGreenRuns;
|
|
136
|
+
await GlobalConfig.setTestTargets({
|
|
137
|
+
windows: { ...windows, greenStreak, ...(promoted ? { policy: "gate" } : {}) },
|
|
138
|
+
});
|
|
139
|
+
return { platform: "windows" as const, greenStreak, promoted };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { PlatformGatePolicy } from "../cloud/constants";
|
|
3
|
+
import { BunTestReport } from "./BunTestReport";
|
|
4
|
+
import type { IdleWatchedResult } from "./IdleWatchedProcess";
|
|
5
|
+
import type { TestSnapshot } from "./TestSnapshot";
|
|
6
|
+
|
|
7
|
+
export type RemoteTestPlatform = "linux" | "windows";
|
|
8
|
+
export type PackageTestStatus = "pass" | "fail" | "hung";
|
|
9
|
+
|
|
10
|
+
export interface PackageTestResult {
|
|
11
|
+
pkg: string;
|
|
12
|
+
status: PackageTestStatus;
|
|
13
|
+
report: BunTestReport;
|
|
14
|
+
ms: number;
|
|
15
|
+
logPath: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PlatformTestResult {
|
|
19
|
+
platform: RemoteTestPlatform;
|
|
20
|
+
policy: PlatformGatePolicy;
|
|
21
|
+
packages: PackageTestResult[];
|
|
22
|
+
envWarnings: string[];
|
|
23
|
+
infraError: string | null;
|
|
24
|
+
ms: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PlatformRunContext {
|
|
28
|
+
snapshot: TestSnapshot;
|
|
29
|
+
envPath: string;
|
|
30
|
+
runId: string;
|
|
31
|
+
logDir: string;
|
|
32
|
+
pkgs: string[];
|
|
33
|
+
onProgress: (message: string) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export abstract class PlatformTestTarget {
|
|
37
|
+
static readonly installIdleMs = 5 * 60_000;
|
|
38
|
+
static readonly testIdleMs = 2 * 60_000;
|
|
39
|
+
protected static readonly gitIdentityEnv = {
|
|
40
|
+
GIT_AUTHOR_NAME: "akan-test",
|
|
41
|
+
GIT_AUTHOR_EMAIL: "akan-test@localhost",
|
|
42
|
+
GIT_COMMITTER_NAME: "akan-test",
|
|
43
|
+
GIT_COMMITTER_EMAIL: "akan-test@localhost",
|
|
44
|
+
GIT_CONFIG_COUNT: "1",
|
|
45
|
+
GIT_CONFIG_KEY_0: "init.defaultBranch",
|
|
46
|
+
GIT_CONFIG_VALUE_0: "main",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
abstract readonly platform: RemoteTestPlatform;
|
|
50
|
+
abstract readonly policy: PlatformGatePolicy;
|
|
51
|
+
protected readonly tolerateScriptFailures: string[] = [];
|
|
52
|
+
|
|
53
|
+
protected abstract prepare(context: PlatformRunContext): Promise<void>;
|
|
54
|
+
protected abstract exec(command: string, logPath: string, idleMs: number): Promise<IdleWatchedResult>;
|
|
55
|
+
protected abstract cleanup(context: PlatformRunContext): Promise<void>;
|
|
56
|
+
|
|
57
|
+
async run(context: PlatformRunContext): Promise<PlatformTestResult> {
|
|
58
|
+
const startedAt = performance.now();
|
|
59
|
+
const envWarnings: string[] = [];
|
|
60
|
+
const packages: PackageTestResult[] = [];
|
|
61
|
+
const result = (infraError: string | null): PlatformTestResult => ({
|
|
62
|
+
platform: this.platform,
|
|
63
|
+
policy: this.policy,
|
|
64
|
+
packages,
|
|
65
|
+
envWarnings,
|
|
66
|
+
infraError,
|
|
67
|
+
ms: performance.now() - startedAt,
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
await this.prepare(context);
|
|
71
|
+
await this.#install(context, envWarnings);
|
|
72
|
+
const build = await this.exec(
|
|
73
|
+
"bun run buildAkan",
|
|
74
|
+
this.#logPath(context, "build"),
|
|
75
|
+
PlatformTestTarget.installIdleMs,
|
|
76
|
+
);
|
|
77
|
+
if (build.exitCode !== 0) throw new Error(`buildAkan failed — see ${this.#logPath(context, "build")}`);
|
|
78
|
+
for (const pkg of context.pkgs) {
|
|
79
|
+
const logPath = this.#logPath(context, `test-${pkg.replaceAll("/", "__")}`);
|
|
80
|
+
const run = await this.exec(
|
|
81
|
+
`bun dist/pkgs/@akanjs/cli/index.js test ${pkg}`,
|
|
82
|
+
logPath,
|
|
83
|
+
PlatformTestTarget.testIdleMs,
|
|
84
|
+
);
|
|
85
|
+
const report = new BunTestReport(run.output);
|
|
86
|
+
const status: PackageTestStatus =
|
|
87
|
+
run.hung || run.timedOut ? "hung" : report.passed && run.exitCode === 0 ? "pass" : "fail";
|
|
88
|
+
packages.push({ pkg, status, report, ms: run.ms, logPath });
|
|
89
|
+
context.onProgress(`[${this.platform}] ${PlatformTestTarget.describe(packages.at(-1) as PackageTestResult)}`);
|
|
90
|
+
}
|
|
91
|
+
return result(null);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return result(error instanceof Error ? error.message : String(error));
|
|
94
|
+
} finally {
|
|
95
|
+
await this.cleanup(context).catch(() => undefined);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static describe({ pkg, status, report, ms }: PackageTestResult) {
|
|
100
|
+
const seconds = `${Math.round(ms / 1000)}s`;
|
|
101
|
+
if (status === "hung") return `${pkg}: hung${report.lastFile ? ` in ${report.lastFile}` : ""} (${seconds})`;
|
|
102
|
+
return `${pkg}: ${report.pass} pass / ${report.fail} fail${report.errors ? ` / ${report.errors} errors` : ""} (${seconds})`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async #install(context: PlatformRunContext, envWarnings: string[]) {
|
|
106
|
+
const logPath = this.#logPath(context, "install");
|
|
107
|
+
const install = await this.exec("bun install --frozen-lockfile", logPath, PlatformTestTarget.installIdleMs);
|
|
108
|
+
if (install.exitCode === 0) return;
|
|
109
|
+
const failedScripts = [...install.output.matchAll(/(?:pre|post)?install script from "([^"]+)" exited/g)].map(
|
|
110
|
+
(match) => match[1] ?? "",
|
|
111
|
+
);
|
|
112
|
+
const tolerable =
|
|
113
|
+
failedScripts.length > 0 && failedScripts.every((pkg) => this.tolerateScriptFailures.includes(pkg));
|
|
114
|
+
if (!tolerable) throw new Error(`bun install failed — see ${logPath}`);
|
|
115
|
+
//* A failed lifecycle script makes bun skip linking the root `.bin`; a second pass without scripts links it.
|
|
116
|
+
envWarnings.push(`install script failed for ${failedScripts.join(", ")} — tolerated on ${this.platform}`);
|
|
117
|
+
const relink = await this.exec(
|
|
118
|
+
"bun install --frozen-lockfile --ignore-scripts",
|
|
119
|
+
this.#logPath(context, "install-relink"),
|
|
120
|
+
PlatformTestTarget.installIdleMs,
|
|
121
|
+
);
|
|
122
|
+
if (relink.exitCode !== 0) throw new Error(`bun install --ignore-scripts failed — see ${logPath}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#logPath(context: PlatformRunContext, name: string) {
|
|
126
|
+
return path.join(context.logDir, this.platform, `${name}.log`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
protected static async spawnText(command: string[], stdin?: string) {
|
|
130
|
+
const proc = Bun.spawn(command, {
|
|
131
|
+
stdin: stdin === undefined ? "ignore" : new Response(stdin),
|
|
132
|
+
stdout: "pipe",
|
|
133
|
+
stderr: "pipe",
|
|
134
|
+
});
|
|
135
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
136
|
+
new Response(proc.stdout).text(),
|
|
137
|
+
new Response(proc.stderr).text(),
|
|
138
|
+
proc.exited,
|
|
139
|
+
]);
|
|
140
|
+
return { stdout, stderr, exitCode };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs, writeText } from "../testHelpers";
|
|
5
|
+
import { TestSnapshot } from "./TestSnapshot";
|
|
6
|
+
|
|
7
|
+
const makeRoot = tempDirs("akan-snapshot-");
|
|
8
|
+
const git = async (cwd: string, ...args: string[]) => {
|
|
9
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
10
|
+
cwd,
|
|
11
|
+
stdout: "ignore",
|
|
12
|
+
stderr: "ignore",
|
|
13
|
+
env: {
|
|
14
|
+
...process.env,
|
|
15
|
+
GIT_AUTHOR_NAME: "t",
|
|
16
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
17
|
+
GIT_COMMITTER_NAME: "t",
|
|
18
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
expect(await proc.exited).toBe(0);
|
|
22
|
+
};
|
|
23
|
+
const write = async (root: string, file: string, content: string) => await writeText(path.join(root, file), content);
|
|
24
|
+
const makeRepo = async () => {
|
|
25
|
+
const root = await makeRoot();
|
|
26
|
+
await git(root, "init", "-q");
|
|
27
|
+
await write(root, ".gitignore", "ignored/\nlocal/\n.env\n");
|
|
28
|
+
await write(root, "tracked.ts", "export {};\n");
|
|
29
|
+
await write(root, "gone.ts", "export {};\n");
|
|
30
|
+
await git(root, "add", ".");
|
|
31
|
+
await git(root, "commit", "-q", "-m", "init");
|
|
32
|
+
await unlink(path.join(root, "gone.ts"));
|
|
33
|
+
await write(root, "untracked.ts", "export const a = 1;\n");
|
|
34
|
+
await write(root, "ignored/secret.ts", "export {};\n");
|
|
35
|
+
await write(root, ".env", "OPENAI_API_KEY=nope\n");
|
|
36
|
+
return root;
|
|
37
|
+
};
|
|
38
|
+
const tarEntries = async (tarPath: string) => {
|
|
39
|
+
const proc = Bun.spawn(["tar", "-tf", tarPath], { stdout: "pipe" });
|
|
40
|
+
return (await new Response(proc.stdout).text()).split(/\r?\n/).filter(Boolean).sort();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe("TestSnapshot", () => {
|
|
44
|
+
test("packs what git would show — tracked and untracked, never ignored or deleted", async () => {
|
|
45
|
+
const root = await makeRepo();
|
|
46
|
+
const snapshot = await TestSnapshot.create(root, path.join(root, "local", "run"));
|
|
47
|
+
|
|
48
|
+
expect(await tarEntries(snapshot.tarPath)).toEqual([".gitignore", "tracked.ts", "untracked.ts"]);
|
|
49
|
+
expect(snapshot.fileCount).toBe(3);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("reports what changed underneath it", async () => {
|
|
53
|
+
const root = await makeRepo();
|
|
54
|
+
const snapshot = await TestSnapshot.create(root, path.join(root, "local", "run"));
|
|
55
|
+
await write(root, "tracked.ts", "export const changed = true;\n");
|
|
56
|
+
await write(root, "added.ts", "export {};\n");
|
|
57
|
+
await unlink(path.join(root, "untracked.ts"));
|
|
58
|
+
|
|
59
|
+
expect(await snapshot.drift()).toEqual({ changed: ["tracked.ts"], added: ["added.ts"], removed: ["untracked.ts"] });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("refuses an env file git does not ignore instead of shipping it to a test host", async () => {
|
|
63
|
+
const root = await makeRepo();
|
|
64
|
+
await write(root, "apps/demo/env/env.server.local.ts", "export default {};\n");
|
|
65
|
+
await write(root, "apps/demo/env/env.server.example.ts", "export default {};\n");
|
|
66
|
+
await write(root, "templates/.env.template", "KEY=\n");
|
|
67
|
+
|
|
68
|
+
await expect(TestSnapshot.create(root, path.join(root, "local", "run"))).rejects.toThrow(
|
|
69
|
+
"apps/demo/env/env.server.local.ts",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|