@akanjs/devkit 2.3.5-rc.9 → 2.3.6-rc.0
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/CHANGELOG.md +7 -0
- package/akanApp/akanApp.host.test.ts +202 -0
- package/akanApp/akanApp.host.ts +311 -27
- package/frontendBuild/devChangePlanner.ts +167 -0
- package/frontendBuild/devGeneratedIndexSync.ts +157 -0
- package/frontendBuild/frontendBuild.test.ts +89 -1
- package/frontendBuild/index.ts +2 -0
- package/incrementalBuilder/devWatchBatch.test.ts +59 -0
- package/incrementalBuilder/devWatchBatch.ts +48 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +69 -13
- package/incrementalBuilder/index.ts +1 -0
- package/integration/devStability.integration.test.ts +214 -0
- package/integration/devStabilityHarness.ts +444 -0
- package/lint/no-deep-internal-import.grit +2 -2
- package/package.json +2 -2
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { DevGeneratedIndexSync } from "../frontendBuild";
|
|
3
|
+
import { DevStabilityHarness } from "./devStabilityHarness";
|
|
4
|
+
|
|
5
|
+
const integrationEnabled = process.env.AKAN_DEV_STABILITY_INTEGRATION === "1";
|
|
6
|
+
const INTEGRATION_TIMEOUT_MS = 60_000;
|
|
7
|
+
const harnesses: DevStabilityHarness[] = [];
|
|
8
|
+
|
|
9
|
+
const integrationTest = (name: string, fn: () => Promise<void>): void => {
|
|
10
|
+
if (integrationEnabled) test(name, fn, INTEGRATION_TIMEOUT_MS);
|
|
11
|
+
else test.skip(name, fn);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const createHarness = async (): Promise<DevStabilityHarness> => {
|
|
15
|
+
const harness = new DevStabilityHarness();
|
|
16
|
+
harnesses.push(harness);
|
|
17
|
+
await harness.createFixture();
|
|
18
|
+
return harness;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const isRefreshMessage = (msg: unknown): boolean =>
|
|
22
|
+
typeof msg === "object" &&
|
|
23
|
+
msg !== null &&
|
|
24
|
+
"type" in msg &&
|
|
25
|
+
(msg.type === "client-refresh" || msg.type === "rsc-refresh" || msg.type === "reload");
|
|
26
|
+
|
|
27
|
+
const isBuildStatus =
|
|
28
|
+
(status: "error" | "ok") =>
|
|
29
|
+
(msg: unknown): boolean =>
|
|
30
|
+
typeof msg === "object" &&
|
|
31
|
+
msg !== null &&
|
|
32
|
+
"type" in msg &&
|
|
33
|
+
msg.type === "build-status" &&
|
|
34
|
+
"status" in msg &&
|
|
35
|
+
msg.status === status;
|
|
36
|
+
|
|
37
|
+
const waitForFileIncludes = async (filePath: string, text: string, timeoutMs = 5_000): Promise<string | null> => {
|
|
38
|
+
const started = Date.now();
|
|
39
|
+
while (Date.now() - started < timeoutMs) {
|
|
40
|
+
const file = Bun.file(filePath);
|
|
41
|
+
const contents = (await file.exists()) ? await file.text() : "";
|
|
42
|
+
if (contents.includes(text)) return contents;
|
|
43
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await Promise.all(harnesses.splice(0).map((harness) => harness.cleanup()));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("dev stability integration harness", () => {
|
|
53
|
+
integrationTest("server-only valid edits restart backend without client refresh", async () => {
|
|
54
|
+
const harness = await createHarness();
|
|
55
|
+
const host = await harness.startHost();
|
|
56
|
+
const hmr = await harness.tryConnectHmrProbe();
|
|
57
|
+
const mark = host.markLog();
|
|
58
|
+
const hmrMark = hmr?.mark() ?? 0;
|
|
59
|
+
|
|
60
|
+
await harness.replaceText("srvkit/backendMarker.ts", "initial-backend-marker", "updated-backend-marker");
|
|
61
|
+
|
|
62
|
+
await host.waitForLogSince(mark, /\[backend-reload\]|Shutting down gracefully|stopping backend/);
|
|
63
|
+
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
64
|
+
expect(host.proc.killed).toBe(false);
|
|
65
|
+
expect(host.logs.join("").slice(mark)).not.toMatch(/\[hmr\].*(client-refresh|rsc-refresh)/);
|
|
66
|
+
await hmr?.waitForNoMessageSince(hmrMark, isRefreshMessage);
|
|
67
|
+
hmr?.close();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
integrationTest("client-only valid edits refresh browser state without backend restart", async () => {
|
|
71
|
+
const harness = await createHarness();
|
|
72
|
+
const host = await harness.startHost();
|
|
73
|
+
const hmr = await harness.tryConnectHmrProbe();
|
|
74
|
+
const initialHtml = await harness.tryWaitForHttpText("initial-client-marker", 3_000);
|
|
75
|
+
if (!initialHtml) {
|
|
76
|
+
expect(host.proc.killed).toBe(false);
|
|
77
|
+
hmr?.close();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const mark = host.markLog();
|
|
81
|
+
const hmrMark = hmr?.mark() ?? 0;
|
|
82
|
+
|
|
83
|
+
await harness.replaceText("ui/ClientMarker.tsx", "initial-client-marker", "updated-client-marker");
|
|
84
|
+
|
|
85
|
+
await host.waitForLogSince(mark, /\[dev-plan\].*roles=.*client.*actions=.*rebuild-client/);
|
|
86
|
+
if (hmr) {
|
|
87
|
+
const message = await hmr.waitForMessageSince(hmrMark, isRefreshMessage);
|
|
88
|
+
expect(message).toBeTruthy();
|
|
89
|
+
} else {
|
|
90
|
+
await host.waitForLogSince(mark, /\[hmr\].*(client-refresh|rsc-refresh|reload)|\[SSR\] pages-updated/);
|
|
91
|
+
}
|
|
92
|
+
expect(host.logs.join("").slice(mark)).not.toMatch(/\[backend-reload\]/);
|
|
93
|
+
hmr?.close();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
integrationTest("shared valid edits rebuild client and restart backend in one generation", async () => {
|
|
97
|
+
const harness = await createHarness();
|
|
98
|
+
const host = await harness.startHost();
|
|
99
|
+
const hmr = await harness.tryConnectHmrProbe();
|
|
100
|
+
const initialHtml = await harness.tryWaitForHttpText("initial-shared-marker", 3_000);
|
|
101
|
+
if (!initialHtml) {
|
|
102
|
+
expect(host.proc.killed).toBe(false);
|
|
103
|
+
hmr?.close();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const mark = host.markLog();
|
|
107
|
+
const hmrMark = hmr?.mark() ?? 0;
|
|
108
|
+
|
|
109
|
+
await harness.replaceText("common/marker.ts", "initial-shared-marker", "updated-shared-marker");
|
|
110
|
+
|
|
111
|
+
const plan = await host.waitForLogSince(
|
|
112
|
+
mark,
|
|
113
|
+
/\[dev-plan\] generation=(\d+).*roles=.*shared.*actions=.*rebuild-client.*restart-backend/,
|
|
114
|
+
);
|
|
115
|
+
const generation = plan[1];
|
|
116
|
+
await host.waitForLogSince(mark, new RegExp(`\\[backend-reload\\].*generation=${generation}`));
|
|
117
|
+
if (hmr)
|
|
118
|
+
await hmr.waitForMessageSince(
|
|
119
|
+
hmrMark,
|
|
120
|
+
(msg) =>
|
|
121
|
+
typeof msg === "object" && msg !== null && "generation" in msg && String(msg.generation) === generation,
|
|
122
|
+
);
|
|
123
|
+
else await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
|
|
124
|
+
await harness.waitForHttpText("updated-shared-marker");
|
|
125
|
+
hmr?.close();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
integrationTest("client build failure reports error and recovers after fix", async () => {
|
|
129
|
+
const harness = await createHarness();
|
|
130
|
+
const host = await harness.startHost();
|
|
131
|
+
const hmr = await harness.tryConnectHmrProbe();
|
|
132
|
+
const initialHtml = await harness.tryWaitForHttpText("initial-client-marker", 3_000);
|
|
133
|
+
if (!initialHtml) {
|
|
134
|
+
expect(host.proc.killed).toBe(false);
|
|
135
|
+
hmr?.close();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const failureMark = host.markLog();
|
|
139
|
+
const failureHmrMark = hmr?.mark() ?? 0;
|
|
140
|
+
|
|
141
|
+
await harness.writeFile(
|
|
142
|
+
"ui/ClientMarker.tsx",
|
|
143
|
+
`export function ClientMarker() {
|
|
144
|
+
return <p>broken</p>
|
|
145
|
+
`,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await host.waitForLogSince(
|
|
149
|
+
failureMark,
|
|
150
|
+
/\[build-status\].*phase=pages.*ok=false|\[build-status\].*phase=csr.*ok=false/,
|
|
151
|
+
);
|
|
152
|
+
if (hmr) await hmr.waitForMessageSince(failureHmrMark, isBuildStatus("error"));
|
|
153
|
+
await harness.waitForHttpText("initial-client-marker");
|
|
154
|
+
const recoveryMark = host.markLog();
|
|
155
|
+
const recoveryHmrMark = hmr?.mark() ?? 0;
|
|
156
|
+
|
|
157
|
+
await harness.writeFile(
|
|
158
|
+
"ui/ClientMarker.tsx",
|
|
159
|
+
`export function ClientMarker() {
|
|
160
|
+
return <p data-testid="client-marker">recovered-client-marker</p>;
|
|
161
|
+
}
|
|
162
|
+
`,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
await host.waitForLogSince(recoveryMark, /\[build-status\].*ok=true/);
|
|
166
|
+
if (hmr) await hmr.waitForMessageSince(recoveryHmrMark, isBuildStatus("ok"));
|
|
167
|
+
await harness.waitForHttpText("recovered-client-marker");
|
|
168
|
+
hmr?.close();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
integrationTest("barrel add/delete includes generated indexes in watch generation", async () => {
|
|
172
|
+
const harness = await createHarness();
|
|
173
|
+
const sync = new DevGeneratedIndexSync({ workspaceRoot: harness.workspaceRoot });
|
|
174
|
+
const facets = ["common", "ui"] as const;
|
|
175
|
+
|
|
176
|
+
for (const facet of facets) {
|
|
177
|
+
const exportName = `${facet}TmpExample`;
|
|
178
|
+
const indexPath = `${facet}/index.ts`;
|
|
179
|
+
const absChangedFile = `${harness.appDir}/${facet}/tmpExample.ts`;
|
|
180
|
+
const absIndexPath = `${harness.appDir}/${indexPath}`;
|
|
181
|
+
|
|
182
|
+
await harness.writeFile(
|
|
183
|
+
`${facet}/tmpExample.ts`,
|
|
184
|
+
`export const ${exportName} = "added-${facet}-example";
|
|
185
|
+
`,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const added = await sync.syncForBatch([absChangedFile]);
|
|
189
|
+
expect(added.errors).toEqual([]);
|
|
190
|
+
expect(added.changedFiles).toContain(absIndexPath);
|
|
191
|
+
const addedIndex = await waitForFileIncludes(absIndexPath, "tmpExample");
|
|
192
|
+
expect(addedIndex).not.toBeNull();
|
|
193
|
+
expect(addedIndex ?? "").toContain("tmpExample");
|
|
194
|
+
|
|
195
|
+
await harness.removeFile(`${facet}/tmpExample.ts`);
|
|
196
|
+
const removed = await sync.syncForBatch([absChangedFile]);
|
|
197
|
+
expect(removed.errors).toEqual([]);
|
|
198
|
+
expect(removed.changedFiles).toContain(absIndexPath);
|
|
199
|
+
const deletedIndex = await waitForFileIncludes(absIndexPath, "tmpExample", 1_000);
|
|
200
|
+
if (deletedIndex) throw new Error(`${indexPath} still contains tmpExample after delete`);
|
|
201
|
+
const finalIndex = await Bun.file(absIndexPath).text();
|
|
202
|
+
expect(finalIndex).toBeString();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
integrationTest("route and css phase-5 scope remains smoke-level in this harness", async () => {
|
|
207
|
+
const manualSmoke = [
|
|
208
|
+
"route add/delete should be covered by a later browser-driven test",
|
|
209
|
+
"css build failure should preserve active stylesheet and report build-status",
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
expect(manualSmoke).toHaveLength(2);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface DevStabilityFixture {
|
|
5
|
+
appName: string;
|
|
6
|
+
appDir: string;
|
|
7
|
+
workspaceRoot: string;
|
|
8
|
+
port: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DevStabilityHost {
|
|
12
|
+
proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
13
|
+
logs: string[];
|
|
14
|
+
markLog(): number;
|
|
15
|
+
waitForLog(pattern: RegExp, timeoutMs?: number): Promise<RegExpMatchArray>;
|
|
16
|
+
waitForLogSince(mark: number, pattern: RegExp, timeoutMs?: number): Promise<RegExpMatchArray>;
|
|
17
|
+
stop(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DevStabilityHmrProbe {
|
|
21
|
+
ws: WebSocket;
|
|
22
|
+
messages: unknown[];
|
|
23
|
+
mark(): number;
|
|
24
|
+
waitForMessageSince(mark: number, predicate: (message: unknown) => boolean, timeoutMs?: number): Promise<unknown>;
|
|
25
|
+
waitForNoMessageSince(mark: number, predicate: (message: unknown) => boolean, quietMs?: number): Promise<void>;
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
30
|
+
|
|
31
|
+
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
|
+
|
|
33
|
+
export class DevStabilityHarness {
|
|
34
|
+
readonly workspaceRoot: string;
|
|
35
|
+
readonly appName: string;
|
|
36
|
+
readonly appDir: string;
|
|
37
|
+
readonly portOffset: number;
|
|
38
|
+
#host: DevStabilityHost | null = null;
|
|
39
|
+
|
|
40
|
+
constructor({
|
|
41
|
+
workspaceRoot = path.resolve(import.meta.dir, "../../../.."),
|
|
42
|
+
appName = `zz-dev-stability-${process.pid}-${Date.now()}`,
|
|
43
|
+
portOffset = 3_000 + Math.floor(Math.random() * 1_000),
|
|
44
|
+
}: {
|
|
45
|
+
workspaceRoot?: string;
|
|
46
|
+
appName?: string;
|
|
47
|
+
portOffset?: number;
|
|
48
|
+
} = {}) {
|
|
49
|
+
this.workspaceRoot = workspaceRoot;
|
|
50
|
+
this.appName = appName;
|
|
51
|
+
this.appDir = path.join(workspaceRoot, "apps", appName);
|
|
52
|
+
this.portOffset = portOffset;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async createFixture(): Promise<DevStabilityFixture> {
|
|
56
|
+
await rm(this.appDir, { recursive: true, force: true });
|
|
57
|
+
await Promise.all([
|
|
58
|
+
mkdir(path.join(this.appDir, "page"), { recursive: true }),
|
|
59
|
+
mkdir(path.join(this.appDir, "common"), { recursive: true }),
|
|
60
|
+
mkdir(path.join(this.appDir, "srvkit"), { recursive: true }),
|
|
61
|
+
mkdir(path.join(this.appDir, "ui"), { recursive: true }),
|
|
62
|
+
mkdir(path.join(this.appDir, "webkit"), { recursive: true }),
|
|
63
|
+
mkdir(path.join(this.appDir, "lib"), { recursive: true }),
|
|
64
|
+
mkdir(path.join(this.appDir, "env"), { recursive: true }),
|
|
65
|
+
mkdir(path.join(this.appDir, "public"), { recursive: true }),
|
|
66
|
+
]);
|
|
67
|
+
await Promise.all([
|
|
68
|
+
this.writeFile(
|
|
69
|
+
"main.ts",
|
|
70
|
+
`import { AkanApp } from "akanjs/server";
|
|
71
|
+
|
|
72
|
+
const run = async () => {
|
|
73
|
+
await new AkanApp("./server").start();
|
|
74
|
+
};
|
|
75
|
+
void run();
|
|
76
|
+
`,
|
|
77
|
+
),
|
|
78
|
+
this.writeFile(
|
|
79
|
+
"akan.config.ts",
|
|
80
|
+
`import type { AppConfig } from "akanjs";
|
|
81
|
+
|
|
82
|
+
const config: AppConfig = {};
|
|
83
|
+
export default config;
|
|
84
|
+
`,
|
|
85
|
+
),
|
|
86
|
+
this.writeFile(
|
|
87
|
+
"package.json",
|
|
88
|
+
`{
|
|
89
|
+
"type": "module",
|
|
90
|
+
"name": "${this.appName}",
|
|
91
|
+
"version": "0.0.1"
|
|
92
|
+
}
|
|
93
|
+
`,
|
|
94
|
+
),
|
|
95
|
+
this.writeFile(
|
|
96
|
+
"tsconfig.json",
|
|
97
|
+
`{
|
|
98
|
+
"extends": "../../tsconfig.json",
|
|
99
|
+
"compilerOptions": {
|
|
100
|
+
"allowJs": true,
|
|
101
|
+
"noEmit": true,
|
|
102
|
+
"incremental": true,
|
|
103
|
+
"resolveJsonModule": true,
|
|
104
|
+
"jsx": "preserve"
|
|
105
|
+
},
|
|
106
|
+
"include": ["./**/*.ts", "./**/*.tsx"]
|
|
107
|
+
}
|
|
108
|
+
`,
|
|
109
|
+
),
|
|
110
|
+
this.writeFile(
|
|
111
|
+
"env/env.client.ts",
|
|
112
|
+
`import { getEnv } from "akanjs/base";
|
|
113
|
+
|
|
114
|
+
export const env = {
|
|
115
|
+
...getEnv(),
|
|
116
|
+
} as const;
|
|
117
|
+
`,
|
|
118
|
+
),
|
|
119
|
+
this.writeFile(
|
|
120
|
+
"env/env.server.ts",
|
|
121
|
+
`import { getEnv } from "akanjs/base";
|
|
122
|
+
|
|
123
|
+
export const env = {
|
|
124
|
+
...getEnv(),
|
|
125
|
+
} as const;
|
|
126
|
+
`,
|
|
127
|
+
),
|
|
128
|
+
this.writeFile(
|
|
129
|
+
"env/env.server.testing.ts",
|
|
130
|
+
`export { env } from "./env.server";
|
|
131
|
+
`,
|
|
132
|
+
),
|
|
133
|
+
this.writeFile(
|
|
134
|
+
"lib/option.ts",
|
|
135
|
+
`import { AkanOption } from "akanjs/server";
|
|
136
|
+
|
|
137
|
+
export type ModulesOptions = Record<string, never>;
|
|
138
|
+
export const option = new AkanOption<ModulesOptions>();
|
|
139
|
+
`,
|
|
140
|
+
),
|
|
141
|
+
this.writeFile(
|
|
142
|
+
"server.ts",
|
|
143
|
+
`import { AkanServer, AkanLib } from "akanjs/server";
|
|
144
|
+
import { backendMarker } from "./srvkit/backendMarker";
|
|
145
|
+
|
|
146
|
+
void backendMarker;
|
|
147
|
+
|
|
148
|
+
export const lib = new AkanLib("${this.appName}", {});
|
|
149
|
+
export const server = new AkanServer("${this.appName}", {
|
|
150
|
+
appName: "${this.appName}",
|
|
151
|
+
env: "local",
|
|
152
|
+
operation: "local",
|
|
153
|
+
publicOrigin: "http://localhost",
|
|
154
|
+
serveDomain: "localhost",
|
|
155
|
+
} as never, undefined, lib);
|
|
156
|
+
`,
|
|
157
|
+
),
|
|
158
|
+
this.writeFile(
|
|
159
|
+
"page/_layout.tsx",
|
|
160
|
+
`import "./styles.css";
|
|
161
|
+
import type { LayoutProps } from "akanjs/client";
|
|
162
|
+
|
|
163
|
+
export default function Layout({ children }: LayoutProps) {
|
|
164
|
+
return <>{children}</>;
|
|
165
|
+
}
|
|
166
|
+
`,
|
|
167
|
+
),
|
|
168
|
+
this.writeFile(
|
|
169
|
+
"page/_index.tsx",
|
|
170
|
+
`import { marker } from "../common/marker";
|
|
171
|
+
import { ClientMarker } from "../ui/ClientMarker";
|
|
172
|
+
|
|
173
|
+
export default function Page() {
|
|
174
|
+
return (
|
|
175
|
+
<main>
|
|
176
|
+
<h1>Dev stability fixture</h1>
|
|
177
|
+
<p data-testid="marker">{marker}</p>
|
|
178
|
+
<ClientMarker />
|
|
179
|
+
</main>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
`,
|
|
183
|
+
),
|
|
184
|
+
this.writeFile(
|
|
185
|
+
"page/styles.css",
|
|
186
|
+
`main {
|
|
187
|
+
color: black;
|
|
188
|
+
}
|
|
189
|
+
`,
|
|
190
|
+
),
|
|
191
|
+
this.writeFile(
|
|
192
|
+
"common/marker.ts",
|
|
193
|
+
`export const marker = "initial-shared-marker";
|
|
194
|
+
`,
|
|
195
|
+
),
|
|
196
|
+
this.writeFile(
|
|
197
|
+
"srvkit/backendMarker.ts",
|
|
198
|
+
`export const backendMarker = "initial-backend-marker";
|
|
199
|
+
`,
|
|
200
|
+
),
|
|
201
|
+
this.writeFile(
|
|
202
|
+
"ui/ClientMarker.tsx",
|
|
203
|
+
`export function ClientMarker() {
|
|
204
|
+
return <p data-testid="client-marker">initial-client-marker</p>;
|
|
205
|
+
}
|
|
206
|
+
`,
|
|
207
|
+
),
|
|
208
|
+
this.writeFile(
|
|
209
|
+
"webkit/useMarker.ts",
|
|
210
|
+
`export const useMarker = () => "initial-webkit-marker";
|
|
211
|
+
`,
|
|
212
|
+
),
|
|
213
|
+
]);
|
|
214
|
+
const port = await this.resolvePort();
|
|
215
|
+
return { appName: this.appName, appDir: this.appDir, workspaceRoot: this.workspaceRoot, port };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async cleanup(): Promise<void> {
|
|
219
|
+
await this.stopHost();
|
|
220
|
+
await rm(this.appDir, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async startHost(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<DevStabilityHost> {
|
|
224
|
+
const logs: string[] = [];
|
|
225
|
+
const proc = Bun.spawn(["bun", "run", "akan", "start", this.appName], {
|
|
226
|
+
cwd: this.workspaceRoot,
|
|
227
|
+
env: {
|
|
228
|
+
...process.env,
|
|
229
|
+
AKAN_VERBOSE: "1",
|
|
230
|
+
NODE_NO_WARNINGS: "1",
|
|
231
|
+
PORT_OFFSET: String(this.portOffset),
|
|
232
|
+
},
|
|
233
|
+
stdout: "pipe",
|
|
234
|
+
stderr: "pipe",
|
|
235
|
+
stdin: "ignore",
|
|
236
|
+
});
|
|
237
|
+
const consume = async (stream: ReadableStream<Uint8Array> | null) => {
|
|
238
|
+
if (!stream) return;
|
|
239
|
+
const decoder = new TextDecoder();
|
|
240
|
+
for await (const chunk of stream) logs.push(decoder.decode(chunk));
|
|
241
|
+
};
|
|
242
|
+
void consume(proc.stdout);
|
|
243
|
+
void consume(proc.stderr);
|
|
244
|
+
const host: DevStabilityHost = {
|
|
245
|
+
proc,
|
|
246
|
+
logs,
|
|
247
|
+
markLog: () => markLog(logs),
|
|
248
|
+
waitForLog: (pattern, waitMs) => waitForLog(logs, pattern, waitMs),
|
|
249
|
+
waitForLogSince: (mark, pattern, waitMs) => waitForLogSince(logs, mark, pattern, waitMs),
|
|
250
|
+
stop: async () => {
|
|
251
|
+
proc.kill("SIGTERM");
|
|
252
|
+
await Promise.race([proc.exited.catch(() => undefined), wait(3_000)]);
|
|
253
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
this.#host = host;
|
|
257
|
+
await host.waitForLog(/backend ready pid=(\d+)|AkanApp gateway is running on port/, timeoutMs);
|
|
258
|
+
return host;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async stopHost(): Promise<void> {
|
|
262
|
+
await this.#host?.stop();
|
|
263
|
+
this.#host = null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async writeFile(relativePath: string, contents: string): Promise<void> {
|
|
267
|
+
const target = path.join(this.appDir, relativePath);
|
|
268
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
269
|
+
await writeFile(target, contents);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async replaceText(relativePath: string, search: string | RegExp, replacement: string): Promise<void> {
|
|
273
|
+
const file = Bun.file(path.join(this.appDir, relativePath));
|
|
274
|
+
const contents = await file.text();
|
|
275
|
+
await this.writeFile(relativePath, contents.replace(search, replacement));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async removeFile(relativePath: string): Promise<void> {
|
|
279
|
+
await rm(path.join(this.appDir, relativePath), { force: true });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async waitForHttpText(text: string | RegExp, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<string> {
|
|
283
|
+
const body = await this.tryWaitForHttpText(text, timeoutMs);
|
|
284
|
+
if (body) return body;
|
|
285
|
+
throw new Error(`Timed out waiting for HTTP text ${String(text)}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async tryWaitForHttpText(text: string | RegExp, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<string | null> {
|
|
289
|
+
const port = await this.resolvePort();
|
|
290
|
+
const started = Date.now();
|
|
291
|
+
while (Date.now() - started < timeoutMs) {
|
|
292
|
+
const body = await fetch(`http://127.0.0.1:${port}/`)
|
|
293
|
+
.then((res) => res.text())
|
|
294
|
+
.catch(() => null);
|
|
295
|
+
if (body && (typeof text === "string" ? body.includes(text) : text.test(body))) return body;
|
|
296
|
+
await wait(100);
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async connectHmr(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<WebSocket> {
|
|
302
|
+
const port = await this.resolvePort();
|
|
303
|
+
const started = Date.now();
|
|
304
|
+
while (Date.now() - started < timeoutMs) {
|
|
305
|
+
const ws = await new Promise<WebSocket | null>((resolve) => {
|
|
306
|
+
const socket = new WebSocket(`ws://127.0.0.1:${port}/_akan/hmr`);
|
|
307
|
+
const timeout = setTimeout(() => {
|
|
308
|
+
socket.close();
|
|
309
|
+
resolve(null);
|
|
310
|
+
}, 750);
|
|
311
|
+
socket.addEventListener("open", () => {
|
|
312
|
+
clearTimeout(timeout);
|
|
313
|
+
resolve(socket);
|
|
314
|
+
});
|
|
315
|
+
socket.addEventListener("error", () => {
|
|
316
|
+
clearTimeout(timeout);
|
|
317
|
+
socket.close();
|
|
318
|
+
resolve(null);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
if (ws) return ws;
|
|
322
|
+
await wait(100);
|
|
323
|
+
}
|
|
324
|
+
throw new Error("Timed out connecting HMR websocket");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async connectHmrProbe(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<DevStabilityHmrProbe> {
|
|
328
|
+
const ws = await this.connectHmr(timeoutMs);
|
|
329
|
+
const messages: unknown[] = [];
|
|
330
|
+
ws.addEventListener("message", (event) => {
|
|
331
|
+
const raw = typeof event.data === "string" ? event.data : "";
|
|
332
|
+
try {
|
|
333
|
+
messages.push(JSON.parse(raw));
|
|
334
|
+
} catch {
|
|
335
|
+
/* ignore non-json websocket payloads */
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
return {
|
|
339
|
+
ws,
|
|
340
|
+
messages,
|
|
341
|
+
mark: () => messages.length,
|
|
342
|
+
waitForMessageSince: (mark, predicate, waitMs) => waitForHmrMessageSince(messages, mark, predicate, waitMs),
|
|
343
|
+
waitForNoMessageSince: (mark, predicate, quietMs) => waitForNoHmrMessageSince(messages, mark, predicate, quietMs),
|
|
344
|
+
close: () => ws.close(),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async tryConnectHmrProbe(timeoutMs = 3_000): Promise<DevStabilityHmrProbe | null> {
|
|
349
|
+
try {
|
|
350
|
+
return await this.connectHmrProbe(timeoutMs);
|
|
351
|
+
} catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async waitForHmrMessage(
|
|
357
|
+
ws: WebSocket,
|
|
358
|
+
predicate: (message: unknown) => boolean,
|
|
359
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
360
|
+
): Promise<unknown> {
|
|
361
|
+
return await new Promise((resolve, reject) => {
|
|
362
|
+
const timeout = setTimeout(() => {
|
|
363
|
+
ws.removeEventListener("message", onMessage);
|
|
364
|
+
reject(new Error("Timed out waiting for HMR message"));
|
|
365
|
+
}, timeoutMs);
|
|
366
|
+
const onMessage = (event: MessageEvent) => {
|
|
367
|
+
const raw = typeof event.data === "string" ? event.data : "";
|
|
368
|
+
let message: unknown;
|
|
369
|
+
try {
|
|
370
|
+
message = JSON.parse(raw);
|
|
371
|
+
} catch {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (!predicate(message)) return;
|
|
375
|
+
clearTimeout(timeout);
|
|
376
|
+
ws.removeEventListener("message", onMessage);
|
|
377
|
+
resolve(message);
|
|
378
|
+
};
|
|
379
|
+
ws.addEventListener("message", onMessage);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async resolvePort(): Promise<number> {
|
|
384
|
+
const apps = await readdir(path.join(this.workspaceRoot, "apps")).catch(() => []);
|
|
385
|
+
const appIndex = Math.max([...new Set([...apps, this.appName])].sort().indexOf(this.appName), 0);
|
|
386
|
+
return 8282 + appIndex + this.portOffset;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function waitForHmrMessageSince(
|
|
391
|
+
messages: unknown[],
|
|
392
|
+
mark: number,
|
|
393
|
+
predicate: (message: unknown) => boolean,
|
|
394
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
395
|
+
): Promise<unknown> {
|
|
396
|
+
const started = Date.now();
|
|
397
|
+
while (Date.now() - started < timeoutMs) {
|
|
398
|
+
const found = messages.slice(mark).find(predicate);
|
|
399
|
+
if (found) return found;
|
|
400
|
+
await wait(50);
|
|
401
|
+
}
|
|
402
|
+
throw new Error(`Timed out waiting for HMR message since mark ${mark}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function waitForNoHmrMessageSince(
|
|
406
|
+
messages: unknown[],
|
|
407
|
+
mark: number,
|
|
408
|
+
predicate: (message: unknown) => boolean,
|
|
409
|
+
quietMs = 750,
|
|
410
|
+
): Promise<void> {
|
|
411
|
+
const started = Date.now();
|
|
412
|
+
while (Date.now() - started < quietMs) {
|
|
413
|
+
const found = messages.slice(mark).find(predicate);
|
|
414
|
+
if (found) throw new Error(`Unexpected HMR message after mark ${mark}: ${JSON.stringify(found)}`);
|
|
415
|
+
await wait(50);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function waitForLog(
|
|
420
|
+
logs: string[],
|
|
421
|
+
pattern: RegExp,
|
|
422
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
423
|
+
): Promise<RegExpMatchArray> {
|
|
424
|
+
return await waitForLogSince(logs, 0, pattern, timeoutMs);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export const markLog = (logs: string[]): number => logs.join("").length;
|
|
428
|
+
|
|
429
|
+
export async function waitForLogSince(
|
|
430
|
+
logs: string[],
|
|
431
|
+
mark: number,
|
|
432
|
+
pattern: RegExp,
|
|
433
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
434
|
+
): Promise<RegExpMatchArray> {
|
|
435
|
+
const started = Date.now();
|
|
436
|
+
while (Date.now() - started < timeoutMs) {
|
|
437
|
+
const joined = logs.join("").slice(mark);
|
|
438
|
+
const match = joined.match(pattern);
|
|
439
|
+
if (match) return match;
|
|
440
|
+
await wait(50);
|
|
441
|
+
}
|
|
442
|
+
const tail = logs.join("").slice(mark).slice(-4_000);
|
|
443
|
+
throw new Error(`Timed out waiting for log pattern ${pattern} since mark ${mark}\nRecent logs:\n${tail}`);
|
|
444
|
+
}
|
|
@@ -15,11 +15,11 @@ or {
|
|
|
15
15
|
JsModuleSource() as $source where {
|
|
16
16
|
$source <: within JsImport(),
|
|
17
17
|
not $filename <: r".*\.(?:test|spec)\.tsx?",
|
|
18
|
-
$filename <: r".*apps/
|
|
18
|
+
$filename <: r".*(?:apps|libs)/[^/]+/lib/[^/]+/[^/]+\.(?:ts|tsx)",
|
|
19
19
|
$source <: r"\"\.\./\.\./.*\"",
|
|
20
20
|
register_diagnostic(
|
|
21
21
|
span = $source,
|
|
22
|
-
message = "
|
|
22
|
+
message = "Module files should not import from two or more parent directories."
|
|
23
23
|
)
|
|
24
24
|
}
|
|
25
25
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.6-rc.0",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@langchain/openai": "^1.4.6",
|
|
33
33
|
"@tailwindcss/node": "^4.3.0",
|
|
34
34
|
"@trapezedev/project": "^7.1.4",
|
|
35
|
-
"akanjs": "2.3.
|
|
35
|
+
"akanjs": "2.3.6-rc.0",
|
|
36
36
|
"chalk": "^5.6.2",
|
|
37
37
|
"commander": "^14.0.3",
|
|
38
38
|
"daisyui": "^5.5.20",
|