@pixel-point/toolcraft 0.0.9 → 0.0.11

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.
Files changed (75) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +14 -6
  4. package/src/generate.test.mjs +28 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +148 -34
  6. package/templates/runtime/contracts/component-contracts.ts +79 -38
  7. package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
  8. package/templates/runtime/contracts/decision-contracts.ts +19 -6
  9. package/templates/runtime/export/export.test.ts +63 -0
  10. package/templates/runtime/export/export.ts +55 -0
  11. package/templates/runtime/index.ts +1 -0
  12. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  13. package/templates/runtime/react/canvas-shell.tsx +31 -9
  14. package/templates/runtime/react/controls-panel.test.tsx +474 -27
  15. package/templates/runtime/react/controls-panel.tsx +71 -26
  16. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  17. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  18. package/templates/runtime/react/settings-transfer.ts +6 -1
  19. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  20. package/templates/runtime/react/timeline-panel.tsx +44 -7
  21. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  22. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  23. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  24. package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
  25. package/templates/runtime/schema/define-toolcraft.ts +117 -247
  26. package/templates/runtime/schema/runtime-targets.ts +21 -0
  27. package/templates/runtime/schema/types.ts +41 -0
  28. package/templates/runtime/state/create-template-state.test.ts +156 -0
  29. package/templates/runtime/state/create-template-state.ts +38 -8
  30. package/templates/runtime/state/media-defaults.ts +105 -0
  31. package/templates/runtime/state/persistence.test.ts +58 -0
  32. package/templates/runtime/state/persistence.ts +105 -1
  33. package/templates/runtime/state/reducer.test.ts +280 -4
  34. package/templates/runtime/state/reducer.ts +195 -9
  35. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  36. package/templates/runtime/state/timeline-loop.ts +35 -0
  37. package/templates/runtime/state/types.ts +27 -0
  38. package/templates/runtime/testing/performance.test.ts +657 -2
  39. package/templates/runtime/testing/performance.ts +789 -49
  40. package/templates/starter/AGENTS.md +22 -16
  41. package/templates/starter/docs/toolcraft/README.md +8 -4
  42. package/templates/starter/docs/toolcraft/acceptance-testing.md +38 -7
  43. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  44. package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
  45. package/templates/starter/docs/toolcraft/component-rules.md +49 -37
  46. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  47. package/templates/starter/docs/toolcraft/performance.md +30 -10
  48. package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
  49. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  50. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  51. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  52. package/templates/starter/e2e/performance-helpers.ts +45 -0
  53. package/templates/starter/index.html +1 -0
  54. package/templates/starter/package.json +1 -0
  55. package/templates/starter/playwright.config.ts +1 -1
  56. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  57. package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
  58. package/templates/starter/scripts/toolcraft-port.mjs +178 -0
  59. package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
  60. package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
  61. package/templates/starter/src/app/starter-acceptance.ts +978 -81
  62. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  63. package/templates/starter/src/app/starter-performance.ts +5 -0
  64. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  65. package/templates/starter/src/app/starter-schema.ts +6 -2
  66. package/templates/starter/vite.config.ts +58 -2
  67. package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
  68. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  69. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  70. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  71. package/templates/ui/components/controls/index.ts +2 -0
  72. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  73. package/templates/ui/components/controls/select/select-control.tsx +4 -26
  74. package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
  75. package/templates/ui/components/panel/panel-actions.tsx +1 -1
@@ -8,6 +8,7 @@ import {
8
8
  killListeningProcessesOnPort,
9
9
  readPreferredPort,
10
10
  readSavedToolcraftPort,
11
+ waitForToolcraftAppOnPort,
11
12
  waitForPortAvailable,
12
13
  writeSavedToolcraftPort,
13
14
  } from "./toolcraft-port.mjs";
@@ -23,13 +24,13 @@ const preferredPort = readPreferredPort([
23
24
  "TOOLCRAFT_PORT",
24
25
  "PORT",
25
26
  ]);
26
- const savedPort = restartSamePort ? await readSavedToolcraftPort() : null;
27
- const port = savedPort ?? (await findAvailablePort(preferredPort));
27
+ const savedPort = await readSavedToolcraftPort();
28
+ let port = savedPort;
28
29
 
29
30
  if (savedPort) {
30
- console.log(`[toolcraft] Restart mode: reusing port ${savedPort}.`);
31
+ console.log(`[toolcraft] Using saved port ${savedPort}.`);
31
32
 
32
- if (!(await isPortAvailableOnLoopback(savedPort))) {
33
+ if (restartSamePort && !(await isPortAvailableOnLoopback(savedPort))) {
33
34
  console.log(`[toolcraft] Port ${savedPort} is busy; stopping the existing listener first.`);
34
35
  await killListeningProcessesOnPort(savedPort);
35
36
 
@@ -41,43 +42,108 @@ if (savedPort) {
41
42
  throw new Error(`Port ${savedPort} is still busy after forced restart cleanup.`);
42
43
  }
43
44
  }
45
+ } else if (!restartSamePort && !(await isPortAvailableOnLoopback(savedPort))) {
46
+ try {
47
+ const existingApp = await waitForToolcraftAppOnPort(savedPort, { timeoutMs: 1_200 });
48
+ console.log(`[toolcraft] This app is already running on ${existingApp.url}`);
49
+ process.exit(0);
50
+ } catch (error) {
51
+ console.error(
52
+ `Saved Toolcraft port ${savedPort} is busy, but it is not serving this app. Use pnpm dev:restart or pnpm preview:restart to reclaim the saved port, or remove .toolcraft/server-port.json to assign a new port. ${error?.message ?? ""}`.trim(),
53
+ );
54
+ process.exit(1);
55
+ }
44
56
  }
45
57
  } else if (restartSamePort) {
46
58
  console.log("[toolcraft] Restart mode requested, but no saved port exists yet.");
47
59
  }
48
60
 
49
- if (!savedPort && port !== preferredPort) {
50
- console.log(`[toolcraft] Port ${preferredPort} is busy; using ${port} instead.`);
51
- }
61
+ if (!port) {
62
+ port = await findAvailablePort(preferredPort);
52
63
 
53
- await writeSavedToolcraftPort(port);
64
+ if (port !== preferredPort) {
65
+ console.log(`[toolcraft] Port ${preferredPort} is busy; using ${port} instead.`);
66
+ }
67
+ }
54
68
 
55
69
  const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
56
- const child = spawn(pnpmCommand, ["exec", "vite", viteCommand, "--port", String(port), ...passthroughArgs], {
57
- env: {
58
- ...process.env,
59
- TOOLCRAFT_ACTIVE_PORT: String(port),
60
- PORT: String(port),
70
+ const child = spawn(
71
+ pnpmCommand,
72
+ [
73
+ "exec",
74
+ "vite",
75
+ viteCommand,
76
+ "--host",
77
+ "127.0.0.1",
78
+ "--port",
79
+ String(port),
80
+ "--strictPort",
81
+ ...passthroughArgs,
82
+ ],
83
+ {
84
+ env: {
85
+ ...process.env,
86
+ TOOLCRAFT_ACTIVE_PORT: String(port),
87
+ PORT: String(port),
88
+ },
89
+ stdio: "inherit",
61
90
  },
62
- stdio: "inherit",
63
- });
91
+ );
64
92
 
65
93
  const signalExitCodes = {
66
94
  SIGINT: 130,
67
95
  SIGTERM: 143,
68
96
  };
69
97
 
98
+ let childExit = null;
99
+ const childExitPromise = new Promise((resolve) => {
100
+ child.on("exit", (code, signal) => {
101
+ childExit = { code, signal };
102
+ resolve(childExit);
103
+ });
104
+ });
105
+
70
106
  for (const signal of ["SIGINT", "SIGTERM"]) {
71
107
  process.on(signal, () => {
72
108
  child.kill(signal);
73
109
  });
74
110
  }
75
111
 
76
- child.on("exit", (code, signal) => {
112
+ function exitFromChild(code, signal) {
77
113
  if (signal) {
78
114
  process.exit(signalExitCodes[signal] ?? 1);
79
115
  return;
80
116
  }
81
117
 
82
118
  process.exit(code ?? 0);
119
+ }
120
+
121
+ try {
122
+ const startupResult = await Promise.race([
123
+ waitForToolcraftAppOnPort(port),
124
+ childExitPromise.then(() => "exited"),
125
+ ]);
126
+
127
+ if (startupResult === "exited") {
128
+ throw new Error(`Vite exited before serving this Toolcraft app on port ${port}.`);
129
+ }
130
+
131
+ await writeSavedToolcraftPort(port);
132
+ console.log(`[toolcraft] Verified this app on ${startupResult.url}`);
133
+ } catch (error) {
134
+ console.error(`[toolcraft] ${error?.message ?? error}`);
135
+
136
+ if (!childExit) {
137
+ child.kill("SIGTERM");
138
+ }
139
+
140
+ process.exit(1);
141
+ }
142
+
143
+ if (childExit) {
144
+ exitFromChild(childExit.code, childExit.signal);
145
+ }
146
+
147
+ childExitPromise.then(({ code, signal }) => {
148
+ exitFromChild(code, signal);
83
149
  });
@@ -5,6 +5,8 @@ import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
 
7
7
  export const DEFAULT_TOOLCRAFT_PORT = 3002;
8
+ export const TOOLCRAFT_APP_TITLE_META_NAME = "toolcraft-app-title";
9
+ export const TOOLCRAFT_SERVER_IDENTITY_PATH = "/.toolcraft/server-identity.json";
8
10
  const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
9
11
  const execFileAsync = promisify(execFile);
10
12
  const PORT_STATE_DIR = ".toolcraft";
@@ -154,3 +156,179 @@ export async function waitForPortAvailable(port, options = {}) {
154
156
 
155
157
  return false;
156
158
  }
159
+
160
+ function extractAttribute(tag, name) {
161
+ const pattern = new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, "i");
162
+ return tag.match(pattern)?.[1] ?? null;
163
+ }
164
+
165
+ export function readToolcraftAppIdentityFromHtml(source) {
166
+ const html = String(source ?? "");
167
+ const title = html.match(/<title>(.*?)<\/title>/is)?.[1]?.trim() ?? null;
168
+ const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
169
+ const appTitleMeta = metaTags.find(
170
+ (tag) => extractAttribute(tag, "name")?.toLowerCase() === TOOLCRAFT_APP_TITLE_META_NAME,
171
+ );
172
+ const appTitle = appTitleMeta ? extractAttribute(appTitleMeta, "content") : null;
173
+
174
+ return {
175
+ appTitle,
176
+ title,
177
+ };
178
+ }
179
+
180
+ async function getRealPath(filePath) {
181
+ try {
182
+ return await fs.realpath(filePath);
183
+ } catch {
184
+ return path.resolve(filePath);
185
+ }
186
+ }
187
+
188
+ export async function readExpectedToolcraftAppIdentity(cwd = process.cwd()) {
189
+ const indexPath = path.join(cwd, "index.html");
190
+ const source = await fs.readFile(indexPath, "utf8");
191
+ const identity = readToolcraftAppIdentityFromHtml(source);
192
+ const expectedLabel = identity.appTitle ?? identity.title;
193
+
194
+ if (!expectedLabel) {
195
+ throw new Error(
196
+ `Cannot verify the Toolcraft server for ${cwd}: index.html has no ${TOOLCRAFT_APP_TITLE_META_NAME} meta tag or title.`,
197
+ );
198
+ }
199
+
200
+ return {
201
+ ...identity,
202
+ root: await getRealPath(cwd),
203
+ };
204
+ }
205
+
206
+ export function toolcraftAppIdentityMatches(actual, expected) {
207
+ if (actual.root && expected.root) {
208
+ return actual.root === expected.root;
209
+ }
210
+
211
+ if (expected.appTitle) {
212
+ return actual.appTitle === expected.appTitle;
213
+ }
214
+
215
+ return actual.title === expected.title;
216
+ }
217
+
218
+ function getToolcraftIdentityLabel(identity) {
219
+ return identity.appTitle ?? identity.title ?? "unknown app";
220
+ }
221
+
222
+ function getToolcraftIdentityDescription(identity) {
223
+ const label = getToolcraftIdentityLabel(identity);
224
+
225
+ return identity.root ? `${label} at ${identity.root}` : label;
226
+ }
227
+
228
+ async function fetchTextWithTimeout(url, options = {}) {
229
+ const { fetchImpl = globalThis.fetch, timeoutMs = 1_000 } = options;
230
+
231
+ if (typeof fetchImpl !== "function") {
232
+ throw new Error("Cannot verify Toolcraft server identity because fetch is unavailable.");
233
+ }
234
+
235
+ const controller = new AbortController();
236
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
237
+
238
+ try {
239
+ const response = await fetchImpl(url, {
240
+ cache: "no-store",
241
+ signal: controller.signal,
242
+ });
243
+
244
+ if (!response.ok) {
245
+ throw new Error(`HTTP ${response.status}`);
246
+ }
247
+
248
+ return await response.text();
249
+ } finally {
250
+ clearTimeout(timeout);
251
+ }
252
+ }
253
+
254
+ async function fetchToolcraftServerIdentity(baseUrl, options = {}) {
255
+ const source = await fetchTextWithTimeout(
256
+ `${baseUrl}${TOOLCRAFT_SERVER_IDENTITY_PATH}?toolcraft-port-check=${Date.now()}`,
257
+ options,
258
+ );
259
+ const parsed = JSON.parse(source);
260
+
261
+ if (!parsed || typeof parsed !== "object") {
262
+ throw new Error("Toolcraft server identity endpoint returned invalid JSON.");
263
+ }
264
+
265
+ if (typeof parsed.root !== "string" || parsed.root.trim() === "") {
266
+ throw new Error("Toolcraft server identity endpoint did not return a project root.");
267
+ }
268
+
269
+ return {
270
+ appTitle: typeof parsed.appTitle === "string" ? parsed.appTitle : null,
271
+ root: await getRealPath(parsed.root),
272
+ title: null,
273
+ };
274
+ }
275
+
276
+ function createHttpBaseUrl(host, port) {
277
+ const formattedHost = host.includes(":") ? `[${host}]` : host;
278
+
279
+ return `http://${formattedHost}:${port}`;
280
+ }
281
+
282
+ export async function waitForToolcraftAppOnPort(port, options = {}) {
283
+ const {
284
+ cwd = process.cwd(),
285
+ fetchImpl = globalThis.fetch,
286
+ host = "127.0.0.1",
287
+ hosts = [host, "::1"],
288
+ intervalMs = 150,
289
+ timeoutMs = 10_000,
290
+ } = options;
291
+ const expected = await readExpectedToolcraftAppIdentity(cwd);
292
+ const expectedDescription = getToolcraftIdentityDescription(expected);
293
+ const deadline = Date.now() + timeoutMs;
294
+ const candidateHosts = [...new Set(hosts)];
295
+ let lastError = null;
296
+
297
+ while (Date.now() <= deadline) {
298
+ for (const candidateHost of candidateHosts) {
299
+ const baseUrl = createHttpBaseUrl(candidateHost, port);
300
+ const url = `${baseUrl}/`;
301
+
302
+ try {
303
+ const actual = await fetchToolcraftServerIdentity(baseUrl, { fetchImpl });
304
+
305
+ if (!toolcraftAppIdentityMatches(actual, expected)) {
306
+ throw new Error(
307
+ `Port ${port} is serving ${getToolcraftIdentityDescription(
308
+ actual,
309
+ )} instead of this app ${expectedDescription}.`,
310
+ );
311
+ }
312
+
313
+ return {
314
+ expected,
315
+ url,
316
+ };
317
+ } catch (error) {
318
+ lastError = error;
319
+
320
+ if (/is serving/.test(String(error?.message))) {
321
+ throw error;
322
+ }
323
+ }
324
+ }
325
+
326
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
327
+ }
328
+
329
+ throw new Error(
330
+ `Timed out waiting for this Toolcraft app ${expectedDescription} on port ${port}. Last error: ${
331
+ lastError?.message ?? "none"
332
+ }`,
333
+ );
334
+ }
@@ -9,7 +9,9 @@ import {
9
9
  findAvailablePort,
10
10
  getListeningProcessIds,
11
11
  killListeningProcessesOnPort,
12
+ readToolcraftAppIdentityFromHtml,
12
13
  readSavedToolcraftPort,
14
+ waitForToolcraftAppOnPort,
13
15
  writeSavedToolcraftPort,
14
16
  } from "./toolcraft-port.mjs";
15
17
 
@@ -130,3 +132,148 @@ test("killListeningProcessesOnPort supports forced restart cleanup", async () =>
130
132
  assert.deepEqual(pids, [789]);
131
133
  assert.deepEqual(killed, [{ pid: 789, signal: "SIGKILL" }]);
132
134
  });
135
+
136
+ test("readToolcraftAppIdentityFromHtml prefers the app title meta marker", () => {
137
+ assert.deepEqual(
138
+ readToolcraftAppIdentityFromHtml(`
139
+ <html>
140
+ <head>
141
+ <meta name="toolcraft-app-title" content="Generated App" />
142
+ <title>Other Title</title>
143
+ </head>
144
+ </html>
145
+ `),
146
+ {
147
+ appTitle: "Generated App",
148
+ title: "Other Title",
149
+ },
150
+ );
151
+ });
152
+
153
+ test("waitForToolcraftAppOnPort accepts the current app identity", async (t) => {
154
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "toolcraft-port-identity-"));
155
+ t.after(() => fs.rm(cwd, { force: true, recursive: true }));
156
+ await fs.writeFile(
157
+ path.join(cwd, "index.html"),
158
+ '<meta name="toolcraft-app-title" content="Current App" /><title>Current App</title>',
159
+ );
160
+ const currentRoot = await fs.realpath(cwd);
161
+
162
+ const result = await waitForToolcraftAppOnPort(4321, {
163
+ cwd,
164
+ fetchImpl: async () => ({
165
+ ok: true,
166
+ text: async () => JSON.stringify({ appTitle: "Current App", root: currentRoot }),
167
+ }),
168
+ timeoutMs: 1,
169
+ });
170
+
171
+ assert.equal(result.url, "http://127.0.0.1:4321/");
172
+ });
173
+
174
+ test("waitForToolcraftAppOnPort verifies the current project root when the server identity endpoint exists", async (t) => {
175
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "toolcraft-port-root-"));
176
+ t.after(() => fs.rm(cwd, { force: true, recursive: true }));
177
+ await fs.writeFile(
178
+ path.join(cwd, "index.html"),
179
+ '<meta name="toolcraft-app-title" content="Current App" /><title>Current App</title>',
180
+ );
181
+ const currentRoot = await fs.realpath(cwd);
182
+
183
+ const result = await waitForToolcraftAppOnPort(4321, {
184
+ cwd,
185
+ fetchImpl: async (url) => ({
186
+ ok: true,
187
+ text: async () =>
188
+ String(url).includes("/.toolcraft/server-identity.json")
189
+ ? JSON.stringify({ appTitle: "Current App", root: currentRoot })
190
+ : '<meta name="toolcraft-app-title" content="Different App" /><title>Different App</title>',
191
+ }),
192
+ timeoutMs: 1,
193
+ });
194
+
195
+ assert.equal(result.url, "http://127.0.0.1:4321/");
196
+ });
197
+
198
+ test("waitForToolcraftAppOnPort checks IPv6 localhost when IPv4 does not respond", async (t) => {
199
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "toolcraft-port-ipv6-"));
200
+ t.after(() => fs.rm(cwd, { force: true, recursive: true }));
201
+ await fs.writeFile(
202
+ path.join(cwd, "index.html"),
203
+ '<meta name="toolcraft-app-title" content="Current App" /><title>Current App</title>',
204
+ );
205
+ const currentRoot = await fs.realpath(cwd);
206
+
207
+ const result = await waitForToolcraftAppOnPort(4321, {
208
+ cwd,
209
+ fetchImpl: async (url) => {
210
+ if (String(url).startsWith("http://127.0.0.1:4321")) {
211
+ throw new Error("fetch failed");
212
+ }
213
+
214
+ return {
215
+ ok: true,
216
+ text: async () =>
217
+ String(url).includes("/.toolcraft/server-identity.json")
218
+ ? JSON.stringify({ appTitle: "Current App", root: currentRoot })
219
+ : '<meta name="toolcraft-app-title" content="Current App" /><title>Current App</title>',
220
+ };
221
+ },
222
+ timeoutMs: 1,
223
+ });
224
+
225
+ assert.equal(result.url, "http://[::1]:4321/");
226
+ });
227
+
228
+ test("waitForToolcraftAppOnPort rejects a port serving another app", async (t) => {
229
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "toolcraft-port-foreign-"));
230
+ t.after(() => fs.rm(cwd, { force: true, recursive: true }));
231
+ await fs.writeFile(
232
+ path.join(cwd, "index.html"),
233
+ '<meta name="toolcraft-app-title" content="Current App" /><title>Current App</title>',
234
+ );
235
+ const otherRoot = await fs.mkdtemp(path.join(os.tmpdir(), "toolcraft-port-foreign-root-"));
236
+ t.after(() => fs.rm(otherRoot, { force: true, recursive: true }));
237
+ const otherRealRoot = await fs.realpath(otherRoot);
238
+
239
+ await assert.rejects(
240
+ () =>
241
+ waitForToolcraftAppOnPort(4321, {
242
+ cwd,
243
+ fetchImpl: async () => ({
244
+ ok: true,
245
+ text: async () => JSON.stringify({ appTitle: "Different App", root: otherRealRoot }),
246
+ }),
247
+ timeoutMs: 1,
248
+ }),
249
+ /Port 4321 is serving Different App at .* instead of this app Current App at /,
250
+ );
251
+ });
252
+
253
+ test("waitForToolcraftAppOnPort rejects a matching title from a different project root", async (t) => {
254
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "toolcraft-port-root-current-"));
255
+ const otherRoot = await fs.mkdtemp(path.join(os.tmpdir(), "toolcraft-port-root-other-"));
256
+ t.after(() => fs.rm(cwd, { force: true, recursive: true }));
257
+ t.after(() => fs.rm(otherRoot, { force: true, recursive: true }));
258
+ await fs.writeFile(
259
+ path.join(cwd, "index.html"),
260
+ '<meta name="toolcraft-app-title" content="Current App" /><title>Current App</title>',
261
+ );
262
+ const otherRealRoot = await fs.realpath(otherRoot);
263
+
264
+ await assert.rejects(
265
+ () =>
266
+ waitForToolcraftAppOnPort(4321, {
267
+ cwd,
268
+ fetchImpl: async (url) => ({
269
+ ok: true,
270
+ text: async () =>
271
+ String(url).includes("/.toolcraft/server-identity.json")
272
+ ? JSON.stringify({ appTitle: "Current App", root: otherRealRoot })
273
+ : '<meta name="toolcraft-app-title" content="Current App" /><title>Current App</title>',
274
+ }),
275
+ timeoutMs: 1,
276
+ }),
277
+ /Port 4321 is serving Current App at .* instead of this app Current App at /,
278
+ );
279
+ });