@akanjs/devkit 3.0.0-beta.17 → 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 +5 -4
- 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 +33 -6
- package/artifact/implicitRootLayout.test.ts +6 -11
- package/artifact/implicitRootLayout.ts +0 -15
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/biome.base.json +6 -1
- package/capacitorApp.test.ts +28 -2
- package/capacitorApp.ts +25 -4
- package/cloud/constants.ts +23 -0
- package/cloud/globalConfig.ts +13 -0
- package/codeAgent/agent/AkanCodeServices.ts +38 -12
- package/codeAgent/agent/CodeAgent.ts +94 -30
- 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/akanCodePaths.ts +23 -3
- package/codeAgent/agent/akanContextFiles.test.ts +2 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -0
- 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/skills/akan-code/SKILL.md +122 -0
- package/codeAgent/skills/akan-module/SKILL.md +2 -2
- package/codeAgent/skills/akan-store/SKILL.md +6 -2
- package/codeAgent/tools/AkanCodePlugins.ts +19 -3
- package/codeAgent/tools/AskToolPack.ts +111 -0
- package/codeAgent/tools/McpClient.ts +51 -5
- package/codeAgent/tools/McpOAuth.ts +268 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
- package/codeAgent/tools/McpServerConfig.ts +69 -34
- package/codeAgent/tools/McpSignIn.ts +161 -0
- package/codeAgent/tools/McpTokenStore.ts +92 -0
- package/codeAgent/tools/McpToolPack.ts +16 -3
- package/codeAgent/tools/askToolPack.test.ts +119 -0
- package/codeAgent/tools/mcpOAuth.test.ts +324 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +106 -12
- package/commandDecorators/command.ts +2 -0
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/helpFormatter.ts +2 -1
- package/dependencyScanner.ts +4 -2
- package/devkitUtils.test.ts +1 -1
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +18 -46
- 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/index.ts +0 -1
- 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/prompter.ts +0 -7
- package/qualityScanner.test.ts +3 -2
- package/qualityScanner.ts +1 -1
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +0 -12
- package/scanInfo.ts +5 -2
- package/subspace.test.ts +5 -2
- package/subspace.ts +19 -8
- package/workflow/executor.ts +52 -0
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +19 -13
- package/guideline.ts +0 -19
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { DevChangePlanner } from "../frontendBuild";
|
|
3
4
|
import { prepareDevWatchBatch } from "./devWatchBatch";
|
|
4
5
|
|
|
5
6
|
describe("prepareDevWatchBatch", () => {
|
|
6
7
|
test("includes generated indexes in the same invalidate generation", () => {
|
|
7
|
-
const root = "/repo";
|
|
8
|
-
const changedFile =
|
|
9
|
-
const generatedIndex =
|
|
8
|
+
const root = path.resolve("/repo");
|
|
9
|
+
const changedFile = path.join(root, "libs/shared/common/foo.ts");
|
|
10
|
+
const generatedIndex = path.join(root, "libs/shared/common/index.ts");
|
|
10
11
|
const prepared = prepareDevWatchBatch({
|
|
11
12
|
generation: 12,
|
|
12
13
|
batch: { files: [changedFile], kinds: new Set(["code"]) },
|
|
@@ -25,9 +26,9 @@ describe("prepareDevWatchBatch", () => {
|
|
|
25
26
|
test.each(["common", "srvkit", "ui", "webkit"])(
|
|
26
27
|
"keeps %s facet add/delete generated index in the same generation",
|
|
27
28
|
(facet) => {
|
|
28
|
-
const root = "/repo";
|
|
29
|
-
const changedFile =
|
|
30
|
-
const generatedIndex =
|
|
29
|
+
const root = path.resolve("/repo");
|
|
30
|
+
const changedFile = path.join(root, "libs/shared", facet, "tmpExample.ts");
|
|
31
|
+
const generatedIndex = path.join(root, "libs/shared", facet, "index.ts");
|
|
31
32
|
const prepared = prepareDevWatchBatch({
|
|
32
33
|
generation: 20,
|
|
33
34
|
batch: { files: [changedFile], kinds: new Set(["code"]) },
|
package/index.ts
CHANGED
|
@@ -32,7 +32,6 @@ export type * from "./fileSys";
|
|
|
32
32
|
export type * from "./frontendBuild";
|
|
33
33
|
export type * from "./getDirname";
|
|
34
34
|
export type * from "./getRelatedCnsts";
|
|
35
|
-
export type * from "./guideline";
|
|
36
35
|
export type * from "./incrementalBuilder";
|
|
37
36
|
export type * from "./mobile";
|
|
38
37
|
export type * from "./packageExportsMap";
|
|
@@ -42,8 +42,13 @@ describe("dev stability harness port allocation", () => {
|
|
|
42
42
|
|
|
43
43
|
expect(new Set(ports).size).toBe(ports.length);
|
|
44
44
|
// Forward-only, so a harness cannot be handed a port a previous host in this process may still be
|
|
45
|
-
// holding while it shuts down — which is the collision the random offset kept producing.
|
|
46
|
-
|
|
45
|
+
// holding while it shuts down — which is the collision the random offset kept producing. The cursor starts
|
|
46
|
+
// at a pid-seeded slot and wraps at the end of the band, so forward is measured around the band, and a step
|
|
47
|
+
// back would read as most of a lap.
|
|
48
|
+
const band = DevStabilityHarness.portOffsetMax - DevStabilityHarness.portOffsetMin;
|
|
49
|
+
const steps = ports.slice(1).map((port, idx) => (port - ports[idx] + band) % band);
|
|
50
|
+
expect(steps.every((step) => step > 0 && step % DevStabilityHarness.portOffsetStride === 0)).toBe(true);
|
|
51
|
+
expect(steps.reduce((sum, step) => sum + step, 0)).toBeLessThan(band);
|
|
47
52
|
});
|
|
48
53
|
|
|
49
54
|
test("returns the same port on every call instead of re-deriving it", async () => {
|
package/lint/gritRules.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
|
|
|
4
4
|
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { Linter } from "../linter";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Fixture tests for the GritQL plugins in this folder.
|
|
@@ -21,7 +22,7 @@ import path from "node:path";
|
|
|
21
22
|
const lintDir = import.meta.dir;
|
|
22
23
|
const fixturesDir = path.join(lintDir, "__fixtures__");
|
|
23
24
|
const workspaceRoot = path.resolve(lintDir, "../../../..");
|
|
24
|
-
const biomeBin = path.join(workspaceRoot, "node_modules
|
|
25
|
+
const biomeBin = path.join(workspaceRoot, "node_modules", ".bin", Linter.biomeBinName);
|
|
25
26
|
|
|
26
27
|
interface BiomeDiagnostic {
|
|
27
28
|
severity: string;
|
|
@@ -15,7 +15,7 @@ or {
|
|
|
15
15
|
JsModuleSource() as $source where {
|
|
16
16
|
$source <: within JsImport(),
|
|
17
17
|
not $filename <: r".*\.(?:test|spec)\.tsx?",
|
|
18
|
-
$filename <: r".*(?:apps|libs)
|
|
18
|
+
$filename <: r".*(?:apps|libs)[/\\][^/\\]+[/\\]lib[/\\][^/\\]+[/\\][^/\\]+\.(?:ts|tsx)",
|
|
19
19
|
$source <: r"\"\.\./\.\./.*\"",
|
|
20
20
|
register_diagnostic(
|
|
21
21
|
span = $source,
|
|
@@ -25,7 +25,7 @@ or {
|
|
|
25
25
|
JsModuleSource() as $source where {
|
|
26
26
|
$source <: within JsImport(),
|
|
27
27
|
not $filename <: r".*\.(?:test|spec)\.tsx?",
|
|
28
|
-
$filename <: r"
|
|
28
|
+
$filename <: r".*[/\\]lib[/\\].+\.tsx",
|
|
29
29
|
$source <: r"\"\.\./.*\"",
|
|
30
30
|
register_diagnostic(
|
|
31
31
|
span = $source,
|
|
@@ -14,7 +14,8 @@ language js(typescript, jsx)
|
|
|
14
14
|
$base <: contains `endpoint`,
|
|
15
15
|
$className <: r"([A-Z][a-zA-Z0-9]*)Endpoint"($Cap)
|
|
16
16
|
},
|
|
17
|
-
|
|
17
|
+
// `$filename` carries backslashes on Windows, so a separator here must accept both.
|
|
18
|
+
$filename <: r".*[/\\]([a-z][a-zA-Z0-9]*)\.signal\.ts"($model),
|
|
18
19
|
or {
|
|
19
20
|
$key <: $model,
|
|
20
21
|
$key <: r"(?:light|create|update|remove|view|edit|merge)([A-Z][a-zA-Z0-9]*)"($keyCap) where {
|
package/linter.ts
CHANGED
|
@@ -112,6 +112,9 @@ const asBiomeReport = (candidate: string): BiomeReport | null => {
|
|
|
112
112
|
const BIOME_CONFIG_FILES = ["biome.json", "biome.jsonc"] as const;
|
|
113
113
|
|
|
114
114
|
export class Linter {
|
|
115
|
+
//? Bun links a package bin as `<name>.exe` (plus a `.bunx` stub) on Windows, and a bare `<name>` elsewhere.
|
|
116
|
+
static readonly biomeBinName = process.platform === "win32" ? "biome.exe" : "biome";
|
|
117
|
+
|
|
115
118
|
lintRoot: string;
|
|
116
119
|
configPath: string;
|
|
117
120
|
#biomeBin: string;
|
|
@@ -119,7 +122,7 @@ export class Linter {
|
|
|
119
122
|
constructor(cwdPath: string) {
|
|
120
123
|
this.lintRoot = this.#findBiomeRootPath(cwdPath);
|
|
121
124
|
this.configPath = Linter.#configPathIn(this.lintRoot) ?? path.join(this.lintRoot, "biome.json");
|
|
122
|
-
const localBiomeBin = path.join(this.lintRoot, "node_modules
|
|
125
|
+
const localBiomeBin = path.join(this.lintRoot, "node_modules", ".bin", Linter.biomeBinName);
|
|
123
126
|
this.#biomeBin = existsSync(localBiomeBin) ? localBiomeBin : "biome";
|
|
124
127
|
}
|
|
125
128
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.19",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@inquirer/prompts": "^8.4.3",
|
|
49
49
|
"@tailwindcss/node": "^4.3.0",
|
|
50
50
|
"@trapezedev/project": "^7.1.4",
|
|
51
|
-
"akanjs": "3.0.0-beta.
|
|
51
|
+
"akanjs": "3.0.0-beta.19",
|
|
52
52
|
"chalk": "^5.6.2",
|
|
53
53
|
"commander": "^14.0.3",
|
|
54
54
|
"dayjs": "^1.11.20",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { BunTestReport } from "./BunTestReport";
|
|
3
|
+
|
|
4
|
+
describe("BunTestReport", () => {
|
|
5
|
+
test("reads the final summary and attributes each failure to the file header above it", () => {
|
|
6
|
+
const report = new BunTestReport(
|
|
7
|
+
[
|
|
8
|
+
"bun test v1.4.2 (744846f84)",
|
|
9
|
+
"",
|
|
10
|
+
"server\\artifact\\routeSeedIndex.test.ts:",
|
|
11
|
+
"(pass) route seed index > keeps seeds [0.40ms]",
|
|
12
|
+
"(fail) route seed index > restores relative seed paths [1.20ms]",
|
|
13
|
+
"",
|
|
14
|
+
"lib/linter.test.ts:",
|
|
15
|
+
"\u001b[31m(fail) Linter > finds biome [3.00ms]\u001b[0m",
|
|
16
|
+
"",
|
|
17
|
+
"2 tests failed:",
|
|
18
|
+
"(fail) route seed index > restores relative seed paths [1.20ms]",
|
|
19
|
+
"(fail) Linter > finds biome [3.00ms]",
|
|
20
|
+
"",
|
|
21
|
+
" 1 pass",
|
|
22
|
+
" 3 skip",
|
|
23
|
+
" 2 fail",
|
|
24
|
+
" 1 error",
|
|
25
|
+
"Ran 6 tests across 2 files. [120.00ms]",
|
|
26
|
+
].join("\r\n"),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(report.hasSummary).toBe(true);
|
|
30
|
+
expect([report.pass, report.fail, report.skip, report.errors, report.files]).toEqual([1, 2, 3, 1, 2]);
|
|
31
|
+
expect(report.failures).toEqual([
|
|
32
|
+
{ file: "server/artifact/routeSeedIndex.test.ts", name: "route seed index > restores relative seed paths" },
|
|
33
|
+
{ file: "lib/linter.test.ts", name: "Linter > finds biome" },
|
|
34
|
+
]);
|
|
35
|
+
expect(report.passed).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a run with no summary is not passed, and counts what it saw before it stopped", () => {
|
|
39
|
+
const report = new BunTestReport(
|
|
40
|
+
[
|
|
41
|
+
"server/akanApp.test.ts:",
|
|
42
|
+
"(pass) AkanApp > boots [1ms]",
|
|
43
|
+
"(pass) AkanApp > relays [2ms]",
|
|
44
|
+
"panic(main thread)",
|
|
45
|
+
].join("\n"),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(report.hasSummary).toBe(false);
|
|
49
|
+
expect(report.passed).toBe(false);
|
|
50
|
+
expect(report.pass).toBe(2);
|
|
51
|
+
expect(report.lastFile).toBe("server/akanApp.test.ts");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a clean summary passes", () => {
|
|
55
|
+
const report = new BunTestReport(" 4 pass\n 0 fail\nRan 4 tests across 1 file. [12.00ms]\n");
|
|
56
|
+
|
|
57
|
+
expect(report.passed).toBe(true);
|
|
58
|
+
expect(report.files).toBe(1);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
|
|
3
|
+
export interface BunTestFailure {
|
|
4
|
+
file: string | null;
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class BunTestReport {
|
|
9
|
+
static readonly #fileHeaderPattern = /^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/;
|
|
10
|
+
//? After the last file, bun repeats every failure under an "N tests failed:" line — those belong to no header.
|
|
11
|
+
static readonly #relistPattern = /^\d+ tests? failed:$/;
|
|
12
|
+
static readonly #durationSuffix = /\s+\[[\d.]+m?s\]$/;
|
|
13
|
+
|
|
14
|
+
readonly pass: number;
|
|
15
|
+
readonly fail: number;
|
|
16
|
+
readonly skip: number;
|
|
17
|
+
readonly errors: number;
|
|
18
|
+
readonly files: number;
|
|
19
|
+
readonly hasSummary: boolean;
|
|
20
|
+
readonly failures: BunTestFailure[];
|
|
21
|
+
readonly lastFile: string | null;
|
|
22
|
+
|
|
23
|
+
constructor(output: string) {
|
|
24
|
+
const lines = stripVTControlCharacters(output).split(/\r?\n/);
|
|
25
|
+
let currentFile: string | null = null;
|
|
26
|
+
let lastFile: string | null = null;
|
|
27
|
+
let relisted = false;
|
|
28
|
+
const failures = new Map<string, BunTestFailure>();
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
if (BunTestReport.#relistPattern.test(line)) relisted = true;
|
|
31
|
+
const header = BunTestReport.#fileHeaderPattern.exec(line);
|
|
32
|
+
if (header?.[1] && !relisted) {
|
|
33
|
+
currentFile = header[1].replaceAll("\\", "/");
|
|
34
|
+
lastFile = currentFile;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!line.startsWith("(fail) ")) continue;
|
|
38
|
+
const name = line.slice("(fail) ".length).replace(BunTestReport.#durationSuffix, "");
|
|
39
|
+
if (!failures.has(name)) failures.set(name, { file: relisted ? null : currentFile, name });
|
|
40
|
+
}
|
|
41
|
+
const ran = lines.findLast((line) => /^Ran \d+ tests? across \d+ files?/.test(line));
|
|
42
|
+
this.hasSummary = !!ran;
|
|
43
|
+
this.files = ran ? Number(/across (\d+) files?/.exec(ran)?.[1] ?? 0) : 0;
|
|
44
|
+
this.failures = [...failures.values()];
|
|
45
|
+
this.lastFile = lastFile;
|
|
46
|
+
this.pass = ran
|
|
47
|
+
? BunTestReport.#lastCount(lines, /^\s*(\d+) pass$/)
|
|
48
|
+
: lines.filter((line) => line.startsWith("(pass) ")).length;
|
|
49
|
+
this.fail = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) fail$/) : this.failures.length;
|
|
50
|
+
this.skip = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) skip$/) : 0;
|
|
51
|
+
this.errors = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) errors?$/) : 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get passed() {
|
|
55
|
+
return this.hasSummary && this.fail === 0 && this.errors === 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static #lastCount(lines: string[], pattern: RegExp) {
|
|
59
|
+
for (const line of lines.toReversed()) {
|
|
60
|
+
const count = pattern.exec(line)?.[1];
|
|
61
|
+
if (count) return Number(count);
|
|
62
|
+
}
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { IdleWatchedProcess } from "./IdleWatchedProcess";
|
|
6
|
+
|
|
7
|
+
const roots: string[] = [];
|
|
8
|
+
const makeLogPath = async () => {
|
|
9
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-idle-watch-"));
|
|
10
|
+
roots.push(root);
|
|
11
|
+
return path.join(root, "nested", "run.log");
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("IdleWatchedProcess", () => {
|
|
19
|
+
test("tees the output of a command that finishes into the log and reports its exit code", async () => {
|
|
20
|
+
const logPath = await makeLogPath();
|
|
21
|
+
const result = await new IdleWatchedProcess(
|
|
22
|
+
[process.execPath, "--eval", "console.log('out'); console.error('err'); process.exit(3)"],
|
|
23
|
+
{ logPath, idleMs: 10_000 },
|
|
24
|
+
).run();
|
|
25
|
+
|
|
26
|
+
expect(result.exitCode).toBe(3);
|
|
27
|
+
expect(result.hung).toBe(false);
|
|
28
|
+
expect(result.output).toContain("out");
|
|
29
|
+
expect(result.output).toContain("err");
|
|
30
|
+
expect(await readFile(logPath, "utf8")).toContain("out");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("gives up on a command that goes quiet, after asking the caller to clean up", async () => {
|
|
34
|
+
const logPath = await makeLogPath();
|
|
35
|
+
let cleanedUp = false;
|
|
36
|
+
const result = await new IdleWatchedProcess(
|
|
37
|
+
[process.execPath, "--eval", "console.log('started'); setInterval(() => {}, 1000)"],
|
|
38
|
+
{
|
|
39
|
+
logPath,
|
|
40
|
+
idleMs: 400,
|
|
41
|
+
onIdle: async () => {
|
|
42
|
+
cleanedUp = true;
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
).run();
|
|
46
|
+
|
|
47
|
+
expect(result.hung).toBe(true);
|
|
48
|
+
expect(result.exitCode).toBeNull();
|
|
49
|
+
expect(cleanedUp).toBe(true);
|
|
50
|
+
expect(result.output).toContain("started");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a command that keeps talking is not idle, but still stops at the total budget", async () => {
|
|
54
|
+
const logPath = await makeLogPath();
|
|
55
|
+
const result = await new IdleWatchedProcess(
|
|
56
|
+
[process.execPath, "--eval", "setInterval(() => console.log('tick'), 50)"],
|
|
57
|
+
{ logPath, idleMs: 5_000, totalMs: 500 },
|
|
58
|
+
).run();
|
|
59
|
+
|
|
60
|
+
expect(result.hung).toBe(false);
|
|
61
|
+
expect(result.timedOut).toBe(true);
|
|
62
|
+
expect(result.exitCode).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
/**
|
|
23
|
+
* Runs a command with its output teed into a log, and gives up on it once the output goes quiet.
|
|
24
|
+
* Silence is the only reliable hang signal: a runner stuck in a synchronous loop (Bun 1.4.2's Windows
|
|
25
|
+
* websocket crash spun at 100% CPU) never lets bun's own per-test timeout fire.
|
|
26
|
+
*/
|
|
27
|
+
export class IdleWatchedProcess {
|
|
28
|
+
readonly #command: string[];
|
|
29
|
+
readonly #options: IdleWatchedOptions;
|
|
30
|
+
|
|
31
|
+
constructor(command: string[], options: IdleWatchedOptions) {
|
|
32
|
+
this.#command = command;
|
|
33
|
+
this.#options = options;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async run(): Promise<IdleWatchedResult> {
|
|
37
|
+
const { logPath, idleMs, totalMs, cwd, env, onIdle, onOutput } = this.#options;
|
|
38
|
+
await mkdir(path.dirname(logPath), { recursive: true });
|
|
39
|
+
const log = Bun.file(logPath).writer();
|
|
40
|
+
const startedAt = performance.now();
|
|
41
|
+
let lastOutputAt = startedAt;
|
|
42
|
+
let output = "";
|
|
43
|
+
let hung = false;
|
|
44
|
+
let timedOut = false;
|
|
45
|
+
const proc = Bun.spawn(this.#command, { cwd, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
46
|
+
const pump = async (stream: ReadableStream<Uint8Array>) => {
|
|
47
|
+
const decoder = new TextDecoder();
|
|
48
|
+
for await (const chunk of stream) {
|
|
49
|
+
lastOutputAt = performance.now();
|
|
50
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
51
|
+
output += text;
|
|
52
|
+
log.write(chunk);
|
|
53
|
+
onOutput?.(text);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const pumping = Promise.all([pump(proc.stdout), pump(proc.stderr)]);
|
|
57
|
+
const watch = setInterval(
|
|
58
|
+
() => {
|
|
59
|
+
const now = performance.now();
|
|
60
|
+
if (now - lastOutputAt >= idleMs) hung = true;
|
|
61
|
+
else if (totalMs !== undefined && now - startedAt >= totalMs) timedOut = true;
|
|
62
|
+
else return;
|
|
63
|
+
clearInterval(watch);
|
|
64
|
+
void (async () => {
|
|
65
|
+
await onIdle?.().catch(() => undefined);
|
|
66
|
+
proc.kill();
|
|
67
|
+
})();
|
|
68
|
+
},
|
|
69
|
+
Math.max(50, Math.min(1000, idleMs / 4)),
|
|
70
|
+
);
|
|
71
|
+
const exitCode = await proc.exited;
|
|
72
|
+
clearInterval(watch);
|
|
73
|
+
await pumping;
|
|
74
|
+
await log.end();
|
|
75
|
+
return { exitCode: hung || timedOut ? null : exitCode, hung, timedOut, output, ms: performance.now() - startedAt };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -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
|
+
});
|