@akanjs/devkit 3.0.0-beta.18 → 3.0.0-beta.19
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 +4 -1
- package/akanApp/akanApp.host.ts +23 -9
- package/akanConfig/akanConfig.test.ts +55 -30
- package/akanConfig/akanConfig.ts +29 -17
- package/akanConfig/types.ts +1 -0
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +29 -3
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/capacitorApp.test.ts +2 -2
- package/cloud/constants.ts +23 -0
- package/cloud/globalConfig.ts +13 -0
- package/codeAgent/agent/AkanCodeServices.ts +22 -3
- package/codeAgent/agent/CodeAgent.ts +33 -18
- package/codeAgent/agent/CodeAgentAsks.ts +3 -1
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
- package/codeAgent/agent/CodeAgentProxy.ts +91 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
- package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +2 -1
- package/codeAgent/agent/codeAgentContract.test.ts +17 -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/tools/mcpOAuth.test.ts +6 -2
- package/dependencyScanner.ts +4 -2
- package/devkitUtils.test.ts +1 -1
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +8 -0
- package/executors.ts +31 -8
- package/frontendBuild/autoImportSync.test.ts +13 -0
- package/frontendBuild/autoImportSync.ts +19 -1
- package/frontendBuild/buildRouteClient.test.ts +52 -1
- package/frontendBuild/cssImportResolver.ts +1 -1
- package/frontendBuild/fontOptimizer.test.ts +1 -1
- package/frontendBuild/frontendBuild.test.ts +19 -17
- package/frontendBuild/hmrWatcher.test.ts +1 -1
- package/frontendBuild/routeClientBuilder.ts +27 -10
- package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
- package/getDirname.test.ts +4 -2
- package/incrementalBuilder/devWatchBatch.test.ts +7 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
- package/integration/devStabilityHarness.test.ts +7 -2
- package/lint/gritRules.test.ts +2 -1
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/linter.ts +4 -1
- package/package.json +2 -2
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +64 -0
- package/platformTest/IdleWatchedProcess.ts +77 -0
- package/platformTest/LinuxDockerTarget.ts +120 -0
- package/platformTest/PlatformTestRun.test.ts +68 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +120 -0
- package/platformTest/TestSnapshot.test.ts +80 -0
- package/platformTest/TestSnapshot.ts +107 -0
- package/platformTest/WindowsSshTarget.ts +167 -0
- package/subspace.test.ts +5 -2
- package/subspace.ts +19 -8
- package/workflow/rolloutGate.test.ts +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
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, on purpose: with a toolchain present at install time ssh2 builds its native
|
|
22
|
+
//* addon, and Bun then panics loading it (`uv_version_string`, oven-sh/bun#18546).
|
|
23
|
+
static readonly dockerfile = [
|
|
24
|
+
`FROM oven/bun:${Bun.version}-slim`,
|
|
25
|
+
"RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates procps lsof && rm -rf /var/lib/apt/lists/*",
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
get image() {
|
|
29
|
+
return `akan-test-linux:bun-${Bun.version}-${Bun.hash(LinuxDockerTarget.dockerfile).toString(16).slice(0, 8)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
protected async prepare(context: PlatformRunContext) {
|
|
33
|
+
if ((await LinuxDockerTarget.#docker(["info"])).exitCode !== 0)
|
|
34
|
+
throw new Error("Docker is not running — start Docker Desktop, or leave linux out of --platforms");
|
|
35
|
+
await this.#ensureImage();
|
|
36
|
+
const container = `akan-test-${context.runId}`;
|
|
37
|
+
const cpus = this.#config.cpus ?? Math.max(2, Math.floor(os.availableParallelism() / 2));
|
|
38
|
+
const started = await LinuxDockerTarget.#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
|
+
["docker", "exec", ...LinuxDockerTarget.#gitIdentityEnv(), container, "sh", "-c", command],
|
|
74
|
+
{
|
|
75
|
+
logPath,
|
|
76
|
+
idleMs,
|
|
77
|
+
onIdle: async () => {
|
|
78
|
+
await LinuxDockerTarget.#docker(["exec", container, "pkill", "-9", "bun"]);
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
).run();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
protected async cleanup() {
|
|
85
|
+
if (this.#container) await LinuxDockerTarget.#docker(["rm", "-f", this.#container]);
|
|
86
|
+
this.#container = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async #ensureImage() {
|
|
90
|
+
if ((await LinuxDockerTarget.#docker(["image", "inspect", this.image])).exitCode === 0) return;
|
|
91
|
+
const built = await LinuxDockerTarget.#docker(["build", "-t", this.image, "-"], LinuxDockerTarget.dockerfile);
|
|
92
|
+
if (built.exitCode !== 0) throw new Error(`building ${this.image} failed: ${built.stderr.trim().slice(-400)}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static #gitIdentityEnv() {
|
|
96
|
+
return Object.entries({
|
|
97
|
+
GIT_AUTHOR_NAME: "akan-test",
|
|
98
|
+
GIT_AUTHOR_EMAIL: "akan-test@localhost",
|
|
99
|
+
GIT_COMMITTER_NAME: "akan-test",
|
|
100
|
+
GIT_COMMITTER_EMAIL: "akan-test@localhost",
|
|
101
|
+
GIT_CONFIG_COUNT: "1",
|
|
102
|
+
GIT_CONFIG_KEY_0: "init.defaultBranch",
|
|
103
|
+
GIT_CONFIG_VALUE_0: "main",
|
|
104
|
+
}).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
static async #docker(args: string[], stdin?: string) {
|
|
108
|
+
const proc = Bun.spawn(["docker", ...args], {
|
|
109
|
+
stdin: stdin === undefined ? "ignore" : new Response(stdin),
|
|
110
|
+
stdout: "pipe",
|
|
111
|
+
stderr: "pipe",
|
|
112
|
+
});
|
|
113
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
114
|
+
new Response(proc.stdout).text(),
|
|
115
|
+
new Response(proc.stderr).text(),
|
|
116
|
+
proc.exited,
|
|
117
|
+
]);
|
|
118
|
+
return { stdout, stderr, exitCode };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { BunTestReport } from "./BunTestReport";
|
|
6
|
+
import { PlatformTestRun } from "./PlatformTestRun";
|
|
7
|
+
import type { PackageTestResult, PlatformTestResult } from "./PlatformTestTarget";
|
|
8
|
+
|
|
9
|
+
const roots: string[] = [];
|
|
10
|
+
afterAll(async () => {
|
|
11
|
+
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const pkg = (status: PackageTestResult["status"]): PackageTestResult => ({
|
|
15
|
+
pkg: "akanjs",
|
|
16
|
+
status,
|
|
17
|
+
report: new BunTestReport(""),
|
|
18
|
+
ms: 1,
|
|
19
|
+
logPath: "x.log",
|
|
20
|
+
});
|
|
21
|
+
const result = (
|
|
22
|
+
platform: PlatformTestResult["platform"],
|
|
23
|
+
policy: PlatformTestResult["policy"],
|
|
24
|
+
packages: PackageTestResult[],
|
|
25
|
+
infraError: string | null = null,
|
|
26
|
+
): PlatformTestResult => ({ platform, policy, packages, envWarnings: [], infraError, ms: 1 });
|
|
27
|
+
|
|
28
|
+
describe("PlatformTestRun", () => {
|
|
29
|
+
test("a failing gate blocks, a failing warn only warns, and a setup failure counts as a failure", () => {
|
|
30
|
+
const verdict = PlatformTestRun.verdict([
|
|
31
|
+
result("linux", "gate", [pkg("pass"), pkg("fail")]),
|
|
32
|
+
result("windows", "warn", [pkg("pass")], "cannot reach user@host over ssh"),
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
expect(verdict.blocking.map((one) => one.platform)).toEqual(["linux"]);
|
|
36
|
+
expect(verdict.warning.map((one) => one.platform)).toEqual(["windows"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a hung package fails its platform", () => {
|
|
40
|
+
expect(PlatformTestRun.failed(result("windows", "gate", [pkg("pass"), pkg("hung")]))).toBe(true);
|
|
41
|
+
expect(PlatformTestRun.failed(result("windows", "gate", [pkg("pass")]))).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("hands a test host only the env keys the CLI needs, never the secrets beside them", async () => {
|
|
45
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-platform-env-"));
|
|
46
|
+
roots.push(root);
|
|
47
|
+
await writeFile(
|
|
48
|
+
path.join(root, ".env"),
|
|
49
|
+
["USE_AKANJS_PKGS=true", "OPENAI_API_KEY=sk-secret", 'AKAN_PUBLIC_SERVE_DOMAIN="akanjs.com"', "JEV_KEY=x"].join(
|
|
50
|
+
"\n",
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(await PlatformTestRun.allowlistedEnv(root)).toBe(
|
|
55
|
+
'USE_AKANJS_PKGS=true\nAKAN_PUBLIC_SERVE_DOMAIN="akanjs.com"\n',
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reads a comma-separated platform list, where none means no remote platform", () => {
|
|
60
|
+
expect(PlatformTestRun.parsePlatforms("linux, Windows,linux")).toEqual(["linux", "windows"]);
|
|
61
|
+
expect(PlatformTestRun.parsePlatforms("none")).toEqual([]);
|
|
62
|
+
expect(() => PlatformTestRun.parsePlatforms("linux,mac")).toThrow("mac");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("run ids sort by time", () => {
|
|
66
|
+
expect(PlatformTestRun.newRunId(new Date(2026, 8, 26, 9, 5, 7))).toBe("20260926-090507");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -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,120 @@
|
|
|
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
|
+
|
|
40
|
+
abstract readonly platform: RemoteTestPlatform;
|
|
41
|
+
abstract readonly policy: PlatformGatePolicy;
|
|
42
|
+
protected readonly tolerateScriptFailures: string[] = [];
|
|
43
|
+
|
|
44
|
+
protected abstract prepare(context: PlatformRunContext): Promise<void>;
|
|
45
|
+
protected abstract exec(command: string, logPath: string, idleMs: number): Promise<IdleWatchedResult>;
|
|
46
|
+
protected abstract cleanup(context: PlatformRunContext): Promise<void>;
|
|
47
|
+
|
|
48
|
+
async run(context: PlatformRunContext): Promise<PlatformTestResult> {
|
|
49
|
+
const startedAt = performance.now();
|
|
50
|
+
const envWarnings: string[] = [];
|
|
51
|
+
const packages: PackageTestResult[] = [];
|
|
52
|
+
const result = (infraError: string | null): PlatformTestResult => ({
|
|
53
|
+
platform: this.platform,
|
|
54
|
+
policy: this.policy,
|
|
55
|
+
packages,
|
|
56
|
+
envWarnings,
|
|
57
|
+
infraError,
|
|
58
|
+
ms: performance.now() - startedAt,
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
await this.prepare(context);
|
|
62
|
+
await this.#install(context, envWarnings);
|
|
63
|
+
const build = await this.exec(
|
|
64
|
+
"bun run buildAkan",
|
|
65
|
+
this.#logPath(context, "build"),
|
|
66
|
+
PlatformTestTarget.installIdleMs,
|
|
67
|
+
);
|
|
68
|
+
if (build.exitCode !== 0) throw new Error(`buildAkan failed — see ${this.#logPath(context, "build")}`);
|
|
69
|
+
for (const pkg of context.pkgs) {
|
|
70
|
+
const logPath = this.#logPath(context, `test-${pkg.replaceAll("/", "__")}`);
|
|
71
|
+
const run = await this.exec(
|
|
72
|
+
`bun dist/pkgs/@akanjs/cli/index.js test ${pkg}`,
|
|
73
|
+
logPath,
|
|
74
|
+
PlatformTestTarget.testIdleMs,
|
|
75
|
+
);
|
|
76
|
+
const report = new BunTestReport(run.output);
|
|
77
|
+
const status: PackageTestStatus =
|
|
78
|
+
run.hung || run.timedOut ? "hung" : report.passed && run.exitCode === 0 ? "pass" : "fail";
|
|
79
|
+
packages.push({ pkg, status, report, ms: run.ms, logPath });
|
|
80
|
+
context.onProgress(`[${this.platform}] ${PlatformTestTarget.describe(packages.at(-1) as PackageTestResult)}`);
|
|
81
|
+
}
|
|
82
|
+
return result(null);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return result(error instanceof Error ? error.message : String(error));
|
|
85
|
+
} finally {
|
|
86
|
+
await this.cleanup(context).catch(() => undefined);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static describe({ pkg, status, report, ms }: PackageTestResult) {
|
|
91
|
+
const seconds = `${Math.round(ms / 1000)}s`;
|
|
92
|
+
if (status === "hung") return `${pkg}: hung${report.lastFile ? ` in ${report.lastFile}` : ""} (${seconds})`;
|
|
93
|
+
return `${pkg}: ${report.pass} pass / ${report.fail} fail${report.errors ? ` / ${report.errors} errors` : ""} (${seconds})`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async #install(context: PlatformRunContext, envWarnings: string[]) {
|
|
97
|
+
const logPath = this.#logPath(context, "install");
|
|
98
|
+
const install = await this.exec("bun install --frozen-lockfile", logPath, PlatformTestTarget.installIdleMs);
|
|
99
|
+
if (install.exitCode === 0) return;
|
|
100
|
+
const failedScripts = [...install.output.matchAll(/(?:pre|post)?install script from "([^"]+)" exited/g)].map(
|
|
101
|
+
(match) => match[1] ?? "",
|
|
102
|
+
);
|
|
103
|
+
const tolerable =
|
|
104
|
+
failedScripts.length > 0 && failedScripts.every((pkg) => this.tolerateScriptFailures.includes(pkg));
|
|
105
|
+
if (!tolerable) throw new Error(`bun install failed — see ${logPath}`);
|
|
106
|
+
//* A failed lifecycle script makes bun skip linking the workspace root's `.bin`, so every tool the suites
|
|
107
|
+
//* spawn from there disappears; a second pass without scripts links them.
|
|
108
|
+
envWarnings.push(`install script failed for ${failedScripts.join(", ")} — tolerated on ${this.platform}`);
|
|
109
|
+
const relink = await this.exec(
|
|
110
|
+
"bun install --frozen-lockfile --ignore-scripts",
|
|
111
|
+
this.#logPath(context, "install-relink"),
|
|
112
|
+
PlatformTestTarget.installIdleMs,
|
|
113
|
+
);
|
|
114
|
+
if (relink.exitCode !== 0) throw new Error(`bun install --ignore-scripts failed — see ${logPath}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#logPath(context: PlatformRunContext, name: string) {
|
|
118
|
+
return path.join(context.logDir, this.platform, `${name}.log`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { TestSnapshot } from "./TestSnapshot";
|
|
6
|
+
|
|
7
|
+
const roots: string[] = [];
|
|
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) => {
|
|
24
|
+
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
|
|
25
|
+
await writeFile(path.join(root, file), content);
|
|
26
|
+
};
|
|
27
|
+
const makeRepo = async () => {
|
|
28
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-snapshot-"));
|
|
29
|
+
roots.push(root);
|
|
30
|
+
await git(root, "init", "-q");
|
|
31
|
+
await write(root, ".gitignore", "ignored/\nlocal/\n.env\n");
|
|
32
|
+
await write(root, "tracked.ts", "export {};\n");
|
|
33
|
+
await write(root, "gone.ts", "export {};\n");
|
|
34
|
+
await git(root, "add", ".");
|
|
35
|
+
await git(root, "commit", "-q", "-m", "init");
|
|
36
|
+
await unlink(path.join(root, "gone.ts"));
|
|
37
|
+
await write(root, "untracked.ts", "export const a = 1;\n");
|
|
38
|
+
await write(root, "ignored/secret.ts", "export {};\n");
|
|
39
|
+
await write(root, ".env", "OPENAI_API_KEY=nope\n");
|
|
40
|
+
return root;
|
|
41
|
+
};
|
|
42
|
+
const tarEntries = async (tarPath: string) => {
|
|
43
|
+
const proc = Bun.spawn(["tar", "-tf", tarPath], { stdout: "pipe" });
|
|
44
|
+
return (await new Response(proc.stdout).text()).split(/\r?\n/).filter(Boolean).sort();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
afterAll(async () => {
|
|
48
|
+
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("TestSnapshot", () => {
|
|
52
|
+
test("packs what git would show — tracked and untracked, never ignored or deleted", async () => {
|
|
53
|
+
const root = await makeRepo();
|
|
54
|
+
const snapshot = await TestSnapshot.create(root, path.join(root, "local", "run"));
|
|
55
|
+
|
|
56
|
+
expect(await tarEntries(snapshot.tarPath)).toEqual([".gitignore", "tracked.ts", "untracked.ts"]);
|
|
57
|
+
expect(snapshot.fileCount).toBe(3);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("reports what changed underneath it", async () => {
|
|
61
|
+
const root = await makeRepo();
|
|
62
|
+
const snapshot = await TestSnapshot.create(root, path.join(root, "local", "run"));
|
|
63
|
+
await write(root, "tracked.ts", "export const changed = true;\n");
|
|
64
|
+
await write(root, "added.ts", "export {};\n");
|
|
65
|
+
await unlink(path.join(root, "untracked.ts"));
|
|
66
|
+
|
|
67
|
+
expect(await snapshot.drift()).toEqual({ changed: ["tracked.ts"], added: ["added.ts"], removed: ["untracked.ts"] });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("refuses an env file git does not ignore instead of shipping it to a test host", async () => {
|
|
71
|
+
const root = await makeRepo();
|
|
72
|
+
await write(root, "apps/demo/env/env.server.local.ts", "export default {};\n");
|
|
73
|
+
await write(root, "apps/demo/env/env.server.example.ts", "export default {};\n");
|
|
74
|
+
await write(root, "templates/.env.template", "KEY=\n");
|
|
75
|
+
|
|
76
|
+
await expect(TestSnapshot.create(root, path.join(root, "local", "run"))).rejects.toThrow(
|
|
77
|
+
"apps/demo/env/env.server.local.ts",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface SnapshotDrift {
|
|
5
|
+
changed: string[];
|
|
6
|
+
added: string[];
|
|
7
|
+
removed: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The exact bytes a platform run tests: every tracked file plus every untracked one git would show, which is
|
|
12
|
+
* what `akan deploy-akan` goes on to build and publish. The per-file hashes are kept so the tree can be
|
|
13
|
+
* compared again before publishing — a test run is only a gate if nothing changed underneath it.
|
|
14
|
+
*/
|
|
15
|
+
export class TestSnapshot {
|
|
16
|
+
static readonly #secretPatterns = [
|
|
17
|
+
/(^|\/)\.env(\.(?!(template|example|sample)$)[^/]+)?$/,
|
|
18
|
+
/(^|\/)env\/env\.(client|server)\.(?!(type|example)\.ts$)[^/]+\.ts$/,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
readonly root: string;
|
|
22
|
+
readonly tarPath: string;
|
|
23
|
+
readonly digest: string;
|
|
24
|
+
readonly #hashes: Map<string, string>;
|
|
25
|
+
|
|
26
|
+
constructor(root: string, tarPath: string, hashes: Map<string, string>) {
|
|
27
|
+
this.root = root;
|
|
28
|
+
this.tarPath = tarPath;
|
|
29
|
+
this.#hashes = hashes;
|
|
30
|
+
this.digest = TestSnapshot.#digestOf(hashes);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get fileCount() {
|
|
34
|
+
return this.#hashes.size;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static async create(root: string, outDir: string) {
|
|
38
|
+
const hashes = await TestSnapshot.#hashTree(root);
|
|
39
|
+
//? A tracked file is already in the repository; only an untracked one nobody ignored can leak by being copied.
|
|
40
|
+
const untracked = await TestSnapshot.#gitList(root, ["--others", "--exclude-standard"]);
|
|
41
|
+
const secrets = untracked.filter((file) => TestSnapshot.#secretPatterns.some((re) => re.test(file)));
|
|
42
|
+
if (secrets.length)
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Refusing to snapshot env files git does not ignore — they would be copied to every test target: ${secrets.join(", ")}`,
|
|
45
|
+
);
|
|
46
|
+
await mkdir(outDir, { recursive: true });
|
|
47
|
+
const listPath = path.join(outDir, "files.txt");
|
|
48
|
+
const tarPath = path.join(outDir, "src.tar");
|
|
49
|
+
await writeFile(listPath, `${[...hashes.keys()].join("\n")}\n`);
|
|
50
|
+
const tar = Bun.spawn(
|
|
51
|
+
["tar", ...(process.platform === "darwin" ? ["--no-xattrs"] : []), "-cf", tarPath, "-T", listPath],
|
|
52
|
+
{ cwd: root, env: { ...process.env, COPYFILE_DISABLE: "1" }, stdout: "ignore", stderr: "pipe" },
|
|
53
|
+
);
|
|
54
|
+
if ((await tar.exited) !== 0) throw new Error(`tar failed: ${await new Response(tar.stderr).text()}`);
|
|
55
|
+
return new TestSnapshot(root, tarPath, hashes);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async drift(): Promise<SnapshotDrift> {
|
|
59
|
+
const current = await TestSnapshot.#hashTree(this.root);
|
|
60
|
+
const changed = [...current].filter(([file, hash]) => this.#hashes.has(file) && this.#hashes.get(file) !== hash);
|
|
61
|
+
return {
|
|
62
|
+
changed: changed.map(([file]) => file),
|
|
63
|
+
added: [...current.keys()].filter((file) => !this.#hashes.has(file)),
|
|
64
|
+
removed: [...this.#hashes.keys()].filter((file) => !current.has(file)),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static async #gitList(root: string, args: string[]) {
|
|
69
|
+
const proc = Bun.spawn(["git", "ls-files", ...args, "-z"], { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
70
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
71
|
+
new Response(proc.stdout).text(),
|
|
72
|
+
new Response(proc.stderr).text(),
|
|
73
|
+
proc.exited,
|
|
74
|
+
]);
|
|
75
|
+
if (exitCode !== 0) throw new Error(`git ls-files failed in ${root}: ${stderr.trim()}`);
|
|
76
|
+
return [...new Set(stdout.split("\0").filter(Boolean))].sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static async #listFiles(root: string) {
|
|
80
|
+
const listed = await TestSnapshot.#gitList(root, ["--cached", "--others", "--exclude-standard"]);
|
|
81
|
+
//? `--cached` still lists a tracked file deleted from the working tree, which tar would then fail on.
|
|
82
|
+
const present = await Promise.all(
|
|
83
|
+
listed.map(async (file) => ((await stat(path.join(root, file)).catch(() => null))?.isFile() ? file : null)),
|
|
84
|
+
);
|
|
85
|
+
return present.filter((file): file is string => file !== null);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static async #hashTree(root: string) {
|
|
89
|
+
const files = await TestSnapshot.#listFiles(root);
|
|
90
|
+
const hashes = new Map<string, string>();
|
|
91
|
+
for (let start = 0; start < files.length; start += 64) {
|
|
92
|
+
const batch = files.slice(start, start + 64);
|
|
93
|
+
const batchHashes = await Promise.all(
|
|
94
|
+
batch.map(async (file) => Bun.hash(await Bun.file(path.join(root, file)).bytes()).toString(16)),
|
|
95
|
+
);
|
|
96
|
+
for (const [idx, file] of batch.entries()) hashes.set(file, batchHashes[idx] ?? "");
|
|
97
|
+
}
|
|
98
|
+
return hashes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static #digestOf(hashes: Map<string, string>) {
|
|
102
|
+
return Bun.hash([...hashes].map(([file, hash]) => `${file}:${hash}`).join("\n"))
|
|
103
|
+
.toString(16)
|
|
104
|
+
.padStart(16, "0")
|
|
105
|
+
.slice(0, 12);
|
|
106
|
+
}
|
|
107
|
+
}
|