@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
|
@@ -333,13 +333,14 @@ describe("RoutesManifestArtifactSerializer", () => {
|
|
|
333
333
|
describe("HmrChangeClassifier", () => {
|
|
334
334
|
test("classifies code, css, config, and ignored files", () => {
|
|
335
335
|
const classifier = new HmrChangeClassifier();
|
|
336
|
-
|
|
337
|
-
expect(classifier.classify("
|
|
338
|
-
expect(classifier.classify("/
|
|
339
|
-
expect(classifier.classify("
|
|
340
|
-
expect(classifier.classify(
|
|
341
|
-
expect(classifier.classify(
|
|
342
|
-
expect(classifier.classify("/
|
|
336
|
+
const app = path.resolve("/repo/apps/demo");
|
|
337
|
+
expect(classifier.classify(path.join(app, "page/_index.tsx"))).toBe("code");
|
|
338
|
+
expect(classifier.classify(path.join(app, "page/styles.css"))).toBe("css");
|
|
339
|
+
expect(classifier.classify(path.join(app, "akan.config.ts"))).toBe("config");
|
|
340
|
+
expect(classifier.classify(path.join(app, ".DS_Store"))).toBe("ignore");
|
|
341
|
+
expect(classifier.classify(path.join(app, "node_modules/pkg/index.ts"))).toBe("ignore");
|
|
342
|
+
expect(classifier.classify(path.join(app, ".akan/generated/page.tsx"))).toBe("ignore");
|
|
343
|
+
expect(classifier.classify(path.join(app, "public/logo.png"))).toBe("ignore");
|
|
343
344
|
});
|
|
344
345
|
});
|
|
345
346
|
|
|
@@ -394,16 +395,16 @@ describe("DevGeneratedIndexSync", () => {
|
|
|
394
395
|
|
|
395
396
|
describe("DevChangePlanner", () => {
|
|
396
397
|
test("classifies server, client, shared, and generated barrel changes", () => {
|
|
397
|
-
const root = "/repo";
|
|
398
|
+
const root = path.resolve("/repo");
|
|
398
399
|
const planner = new DevChangePlanner({ workspaceRoot: root });
|
|
399
|
-
const generatedIndex =
|
|
400
|
+
const generatedIndex = path.join(root, "libs/shared/common/index.ts");
|
|
400
401
|
const plan = planner.plan({
|
|
401
402
|
generation: 7,
|
|
402
403
|
files: [
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
404
|
+
path.join(root, "libs/shared/lib/admin/admin.service.ts"),
|
|
405
|
+
path.join(root, "libs/shared/lib/admin/Admin.Template.tsx"),
|
|
406
|
+
path.join(root, "libs/shared/lib/admin/admin.constant.ts"),
|
|
407
|
+
path.join(root, "libs/shared/common/foo.ts"),
|
|
407
408
|
],
|
|
408
409
|
kinds: ["code"],
|
|
409
410
|
generatedFiles: [generatedIndex],
|
|
@@ -429,21 +430,22 @@ describe("DevChangePlanner", () => {
|
|
|
429
430
|
});
|
|
430
431
|
|
|
431
432
|
test("recycles builder for macro-backed dictionary and signal metadata changes", () => {
|
|
432
|
-
const root = "/repo";
|
|
433
|
+
const root = path.resolve("/repo");
|
|
433
434
|
const planner = new DevChangePlanner({ workspaceRoot: root });
|
|
435
|
+
const dictionaryFile = path.join(root, "apps/demo/lib/_demo/demo.dictionary.ts");
|
|
434
436
|
const dictionaryPlan = planner.plan({
|
|
435
437
|
generation: 3,
|
|
436
|
-
files: [
|
|
438
|
+
files: [dictionaryFile],
|
|
437
439
|
kinds: ["code"],
|
|
438
440
|
});
|
|
439
441
|
const signalPlan = planner.plan({
|
|
440
442
|
generation: 4,
|
|
441
|
-
files: [
|
|
443
|
+
files: [path.join(root, "libs/shared/lib/admin/admin.signal.ts")],
|
|
442
444
|
kinds: ["code"],
|
|
443
445
|
});
|
|
444
446
|
|
|
445
447
|
expect(dictionaryPlan.actions).toEqual(["rebuild-client", "restart-backend", "restart-builder"]);
|
|
446
|
-
expect(dictionaryPlan.reasonByFile[
|
|
448
|
+
expect(dictionaryPlan.reasonByFile[dictionaryFile]).toContain("runtime-metadata");
|
|
447
449
|
expect(signalPlan.actions).toContain("restart-builder");
|
|
448
450
|
});
|
|
449
451
|
});
|
|
@@ -161,7 +161,7 @@ describe("HmrWatcher", () => {
|
|
|
161
161
|
TEST_TIMEOUT_MS,
|
|
162
162
|
);
|
|
163
163
|
|
|
164
|
-
test.skipIf(process.getuid?.() === 0)(
|
|
164
|
+
test.skipIf(process.getuid?.() === 0 || process.platform === "win32")(
|
|
165
165
|
"warns at startup when part of the tree cannot be read",
|
|
166
166
|
async () => {
|
|
167
167
|
const root = await makeRoot();
|
|
@@ -28,6 +28,15 @@ const SSR_CLIENT_ALIAS_EXTERNALS = [
|
|
|
28
28
|
export interface BuildRouteClientOptions {
|
|
29
29
|
app: App;
|
|
30
30
|
seeds: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Seeds of every route, so a build that has to run at all bundles the whole app's client entries.
|
|
33
|
+
*
|
|
34
|
+
* A page loads entries first built for other routes, and entries from two `Bun.build` runs each carry
|
|
35
|
+
* their own copy of every app and lib module — one page evaluated a lib's module-scope registration
|
|
36
|
+
* twice. The whole app costs about what one route did, since every entry already imports the client
|
|
37
|
+
* barrel, and content-hashed chunk names keep unchanged chunks at the URLs the browser already holds.
|
|
38
|
+
*/
|
|
39
|
+
graphSeeds?: string[];
|
|
31
40
|
artifact: BaseBuildArtifact;
|
|
32
41
|
knownEntries?: Set<string>;
|
|
33
42
|
routeId?: string;
|
|
@@ -39,13 +48,13 @@ export interface BuildRouteClientOptions {
|
|
|
39
48
|
* `AllRoutesBuilder` passes every route's entries at once so they share one `Bun.build`. Chunk
|
|
40
49
|
* splitting is scoped to a single invocation, so a dependency reachable from entries spread across
|
|
41
50
|
* several invocations is emitted once per invocation — mermaid landed in `apps/akan` four times that
|
|
42
|
-
* way. Dev
|
|
51
|
+
* way. Dev passes `graphSeeds` instead, for the same reason.
|
|
43
52
|
*/
|
|
44
53
|
entries?: string[];
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
export interface BuildRouteClientResult {
|
|
48
|
-
/** Newly-emitted manifest rows keyed by `${absEntry}#${exportName}
|
|
57
|
+
/** Newly-emitted manifest rows keyed by `${absEntry}#${exportName}`, for every entry this build bundled. */
|
|
49
58
|
manifestDelta: ClientManifest;
|
|
50
59
|
/** Newly-emitted ssrManifest rows keyed by entry URL. */
|
|
51
60
|
ssrManifestDelta: SsrManifest;
|
|
@@ -53,7 +62,7 @@ export interface BuildRouteClientResult {
|
|
|
53
62
|
newEntries: string[];
|
|
54
63
|
/** Absolute paths of all `"use client"` leaves discovered from this route's seeds. */
|
|
55
64
|
discoveredEntries?: string[];
|
|
56
|
-
/** Absolute source files included in the browser bundles for
|
|
65
|
+
/** Absolute source files included in the browser bundles for this route's bundled entries. */
|
|
57
66
|
clientDeps: string[];
|
|
58
67
|
/** Absolute source files included in the browser bundle, grouped by original client entry. */
|
|
59
68
|
clientDepsByEntry?: Record<string, string[]>;
|
|
@@ -67,6 +76,7 @@ interface BootstrapEntries {
|
|
|
67
76
|
export class RouteClientBuilder {
|
|
68
77
|
#app: App;
|
|
69
78
|
#seeds: string[];
|
|
79
|
+
#graphSeeds?: string[];
|
|
70
80
|
#knownEntries: Set<string>;
|
|
71
81
|
#command: "build" | "start";
|
|
72
82
|
#discovery?: ClientEntryDiscovery;
|
|
@@ -75,6 +85,7 @@ export class RouteClientBuilder {
|
|
|
75
85
|
constructor(options: BuildRouteClientOptions) {
|
|
76
86
|
this.#app = options.app;
|
|
77
87
|
this.#seeds = options.seeds;
|
|
88
|
+
this.#graphSeeds = options.graphSeeds;
|
|
78
89
|
this.#knownEntries = options.knownEntries ?? new Set<string>();
|
|
79
90
|
this.#command = options.command ?? "start";
|
|
80
91
|
this.#discovery = options.discovery;
|
|
@@ -82,17 +93,18 @@ export class RouteClientBuilder {
|
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
async build(): Promise<BuildRouteClientResult> {
|
|
85
|
-
const discovered =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
const discovered = this.#entries ?? (await (await this.#getDiscovery()).discover(this.#seeds));
|
|
97
|
+
if (discovered.every((e) => this.#knownEntries.has(e))) return this.#emptyResult(discovered);
|
|
98
|
+
const entries = this.#graphSeeds
|
|
99
|
+
? [...new Set([...discovered, ...(await (await this.#getDiscovery()).discover(this.#graphSeeds))])].sort()
|
|
100
|
+
: discovered.filter((e) => !this.#knownEntries.has(e));
|
|
90
101
|
|
|
91
102
|
const bootstrapEntries = await this.#createBootstrapEntries(entries);
|
|
92
103
|
const browserBundle = await this.#buildBrowserBundle(bootstrapEntries);
|
|
93
104
|
const ssrBundle = await this.#buildSsrBundle(bootstrapEntries);
|
|
94
105
|
|
|
95
106
|
const acceptedEntries = new Set(entries);
|
|
107
|
+
const routeEntries = new Set(discovered);
|
|
96
108
|
const manifestDelta: ClientManifest = {};
|
|
97
109
|
const ssrModuleMap: SsrManifest["moduleMap"] = {};
|
|
98
110
|
const clientDeps = new Set<string>();
|
|
@@ -122,19 +134,24 @@ export class RouteClientBuilder {
|
|
|
122
134
|
for (const dep of browserBundle.entryDepsByAbsPath.get(buildEntry) ?? []) deps.add(path.resolve(dep));
|
|
123
135
|
const sortedDeps = [...deps].sort();
|
|
124
136
|
clientDepsByEntry[originalEntry] = sortedDeps;
|
|
125
|
-
for (const dep of sortedDeps) clientDeps.add(dep);
|
|
137
|
+
if (routeEntries.has(originalEntry)) for (const dep of sortedDeps) clientDeps.add(dep);
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
return {
|
|
129
141
|
manifestDelta,
|
|
130
142
|
ssrManifestDelta: { moduleLoading: null, moduleMap: ssrModuleMap },
|
|
131
|
-
newEntries: entries,
|
|
143
|
+
newEntries: entries.filter((e) => !this.#knownEntries.has(e)),
|
|
132
144
|
discoveredEntries: discovered,
|
|
133
145
|
clientDeps: [...clientDeps].sort(),
|
|
134
146
|
clientDepsByEntry,
|
|
135
147
|
};
|
|
136
148
|
}
|
|
137
149
|
|
|
150
|
+
async #getDiscovery(): Promise<ClientEntryDiscovery> {
|
|
151
|
+
this.#discovery ??= await GraphClientEntryDiscovery.create(this.#app);
|
|
152
|
+
return this.#discovery;
|
|
153
|
+
}
|
|
154
|
+
|
|
138
155
|
#emptyResult(discoveredEntries: string[] = []): BuildRouteClientResult {
|
|
139
156
|
return {
|
|
140
157
|
manifestDelta: {},
|
|
@@ -249,11 +249,12 @@ describe("SourceMtimeIndex", () => {
|
|
|
249
249
|
describe("a directory it cannot read", () => {
|
|
250
250
|
/**
|
|
251
251
|
* `chmod 000` does not stop root, so these would assert the opposite of what they mean when the suite
|
|
252
|
-
* runs as root (CI containers commonly do).
|
|
252
|
+
* runs as root (CI containers commonly do). On Windows it only sets the read-only attribute, which leaves
|
|
253
|
+
* a directory listable.
|
|
253
254
|
*/
|
|
254
|
-
const
|
|
255
|
+
const cannotLock = process.getuid?.() === 0 || process.platform === "win32";
|
|
255
256
|
|
|
256
|
-
test.skipIf(
|
|
257
|
+
test.skipIf(cannotLock)("is reported as a gap instead of silently skipped", async () => {
|
|
257
258
|
const root = await makeRoot();
|
|
258
259
|
await seed(root, "open/a.ts");
|
|
259
260
|
const hidden = await seed(root, "locked/b.ts");
|
|
@@ -269,7 +270,7 @@ describe("SourceMtimeIndex", () => {
|
|
|
269
270
|
expect(index.coverageGaps).toEqual([{ path: path.dirname(hidden), code: "EACCES" }]);
|
|
270
271
|
});
|
|
271
272
|
|
|
272
|
-
test.skipIf(
|
|
273
|
+
test.skipIf(cannotLock)("becomes visible again once it is readable, and clears the gap", async () => {
|
|
273
274
|
const root = await makeRoot();
|
|
274
275
|
const hidden = await seed(root, "locked/b.ts");
|
|
275
276
|
const locked = path.dirname(hidden);
|
|
@@ -287,7 +288,7 @@ describe("SourceMtimeIndex", () => {
|
|
|
287
288
|
expect(await index.collectChanges()).toEqual([hidden]);
|
|
288
289
|
});
|
|
289
290
|
|
|
290
|
-
test.skipIf(
|
|
291
|
+
test.skipIf(cannotLock)("does not report the files under it as deleted while it is unreadable", async () => {
|
|
291
292
|
const root = await makeRoot();
|
|
292
293
|
const abs = await seed(root, "locked/b.ts");
|
|
293
294
|
const locked = path.dirname(abs);
|
package/getDirname.test.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
3
4
|
import { getDirname } from "./getDirname";
|
|
4
5
|
|
|
5
6
|
describe("getDirname", () => {
|
|
6
7
|
test("converts file URLs to filesystem paths", () => {
|
|
7
|
-
const
|
|
8
|
+
const filePath = path.resolve("/tmp/akan workspace/index.ts");
|
|
9
|
+
const dirname = getDirname(pathToFileURL(filePath).href);
|
|
8
10
|
|
|
9
|
-
expect(dirname).toBe(path.
|
|
11
|
+
expect(dirname).toBe(path.dirname(filePath));
|
|
10
12
|
});
|
|
11
13
|
|
|
12
14
|
test("keeps Windows drive paths valid when running on Windows", () => {
|
|
@@ -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"]) },
|
|
@@ -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
|
+
}
|