@pixel-point/toolcraft 0.0.7 → 0.0.9
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/package.json +1 -1
- package/src/generate.mjs +34 -5
- package/src/generate.test.mjs +12 -0
- package/src/package-json.mjs +15 -0
- package/src/package-json.test.mjs +14 -1
- package/templates/runtime/contracts/component-contracts.test.ts +185 -23
- package/templates/runtime/contracts/component-contracts.ts +91 -36
- package/templates/runtime/contracts/decision-contracts.test.ts +5 -0
- package/templates/runtime/contracts/decision-contracts.ts +4 -4
- package/templates/runtime/export/export.test.ts +31 -0
- package/templates/runtime/export/export.ts +41 -0
- package/templates/runtime/react/canvas-shell.test.tsx +77 -1
- package/templates/runtime/react/canvas-shell.tsx +178 -23
- package/templates/runtime/react/control-conditions.ts +166 -0
- package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
- package/templates/runtime/react/controls-panel.test.tsx +774 -17
- package/templates/runtime/react/controls-panel.tsx +155 -8
- package/templates/runtime/react/media-file.ts +19 -0
- package/templates/runtime/schema/define-toolcraft.test.ts +46 -1
- package/templates/runtime/schema/define-toolcraft.ts +29 -3
- package/templates/runtime/schema/types.ts +7 -0
- package/templates/runtime/state/reducer.test.ts +304 -0
- package/templates/runtime/state/reducer.ts +148 -9
- package/templates/runtime/state/types.ts +10 -2
- package/templates/runtime/testing/performance.test.ts +1424 -56
- package/templates/runtime/testing/performance.ts +710 -43
- package/templates/starter/AGENTS.md +10 -9
- package/templates/starter/docs/toolcraft/README.md +1 -1
- package/templates/starter/docs/toolcraft/acceptance-testing.md +16 -8
- package/templates/starter/docs/toolcraft/assembly-workflow.md +13 -7
- package/templates/starter/docs/toolcraft/component-rules.md +46 -16
- package/templates/starter/docs/toolcraft/custom-controls.md +8 -4
- package/templates/starter/docs/toolcraft/performance.md +54 -6
- package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
- package/templates/starter/docs/toolcraft/schema-reference.md +48 -19
- package/templates/starter/docs/toolcraft/workflow.md +2 -2
- package/templates/starter/e2e/app-performance.spec.ts +136 -3
- package/templates/starter/e2e/performance-helpers.ts +197 -0
- package/templates/starter/gitignore +1 -0
- package/templates/starter/package.json +5 -0
- package/templates/starter/scripts/run-vite-on-free-port.mjs +39 -4
- package/templates/starter/scripts/toolcraft-port.mjs +102 -0
- package/templates/starter/scripts/toolcraft-port.test.mjs +60 -1
- package/templates/starter/src/app/starter-acceptance.test.ts +1288 -105
- package/templates/starter/src/app/starter-acceptance.ts +740 -24
- package/templates/starter/src/app/starter-performance.test.ts +66 -5
- package/templates/ui/components/control-layout/index.tsx +8 -3
- package/templates/ui/components/controls/actions/actions-control.tsx +10 -4
- package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
- package/templates/ui/components/controls/color/index.ts +4 -1
- package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
- package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
- package/templates/ui/components/controls/file-drop/index.ts +1 -1
- package/templates/ui/components/controls/index.ts +3 -0
- package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
- package/templates/ui/components/controls/select/select-control.tsx +9 -4
- package/templates/ui/components/controls/slider/slider-value.ts +0 -1
- package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
- package/templates/ui/components/controls/vector/index.ts +1 -0
- package/templates/ui/components/controls/vector/vector-control.tsx +84 -8
- package/templates/ui/components/panel/panel-section.tsx +82 -6
|
@@ -21,6 +21,26 @@ export type ToolcraftInteractionOptions = {
|
|
|
21
21
|
settleMs?: number;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
export type ToolcraftStressFixtureApplyContext = {
|
|
25
|
+
config: ToolcraftPerformanceConfig;
|
|
26
|
+
fixture: Record<string, unknown>;
|
|
27
|
+
key: string;
|
|
28
|
+
page: Page;
|
|
29
|
+
scenarioId: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ToolcraftStressFixtureAppliers = Record<
|
|
33
|
+
string,
|
|
34
|
+
(
|
|
35
|
+
value: unknown,
|
|
36
|
+
context: ToolcraftStressFixtureApplyContext,
|
|
37
|
+
) => Promise<void> | void
|
|
38
|
+
>;
|
|
39
|
+
|
|
40
|
+
function isToolcraftStressFixtureObject(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
export async function startToolcraftFrameProbe(
|
|
25
45
|
page: Page,
|
|
26
46
|
): Promise<() => Promise<ToolcraftFrameProbeResult>> {
|
|
@@ -371,6 +391,161 @@ export async function dragToolcraftSliderByLabel(
|
|
|
371
391
|
await page.mouse.up();
|
|
372
392
|
}
|
|
373
393
|
|
|
394
|
+
export async function dragToolcraftSliderToValue(
|
|
395
|
+
page: Page,
|
|
396
|
+
label: string,
|
|
397
|
+
value: number,
|
|
398
|
+
): Promise<void> {
|
|
399
|
+
const field = await getToolcraftFieldByLabel(page, label);
|
|
400
|
+
const slider = field.locator('[data-slot="slider"], [role="slider"]').first();
|
|
401
|
+
|
|
402
|
+
await expect(slider, `Toolcraft slider "${label}" should be visible`).toBeVisible();
|
|
403
|
+
|
|
404
|
+
const range = await slider.evaluate((element) => {
|
|
405
|
+
const htmlElement = element as HTMLElement;
|
|
406
|
+
const min = Number(
|
|
407
|
+
htmlElement.getAttribute("aria-valuemin") ??
|
|
408
|
+
(htmlElement as HTMLInputElement).min ??
|
|
409
|
+
"0",
|
|
410
|
+
);
|
|
411
|
+
const max = Number(
|
|
412
|
+
htmlElement.getAttribute("aria-valuemax") ??
|
|
413
|
+
(htmlElement as HTMLInputElement).max ??
|
|
414
|
+
"100",
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
max: Number.isFinite(max) ? max : 100,
|
|
419
|
+
min: Number.isFinite(min) ? min : 0,
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
const denominator = range.max - range.min;
|
|
423
|
+
const ratio = denominator === 0 ? 0 : (value - range.min) / denominator;
|
|
424
|
+
|
|
425
|
+
await dragToolcraftSliderByLabel(page, label, Math.min(1, Math.max(0, ratio)));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function dragToolcraftSliderToPerformanceStressValue(
|
|
429
|
+
page: Page,
|
|
430
|
+
label: string,
|
|
431
|
+
config: ToolcraftPerformanceConfig,
|
|
432
|
+
scenarioId: string,
|
|
433
|
+
): Promise<void> {
|
|
434
|
+
const value = getToolcraftPerformanceStressValue(config, scenarioId);
|
|
435
|
+
|
|
436
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
437
|
+
throw new Error(
|
|
438
|
+
`Toolcraft performance scenario "${scenarioId}" must provide a numeric stressFixture.value for slider "${label}".`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
await dragToolcraftSliderToValue(page, label, value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function applyToolcraftPerformanceStressFixture(
|
|
446
|
+
page: Page,
|
|
447
|
+
config: ToolcraftPerformanceConfig,
|
|
448
|
+
scenarioId: string,
|
|
449
|
+
appliers: ToolcraftStressFixtureAppliers,
|
|
450
|
+
): Promise<Record<string, unknown>> {
|
|
451
|
+
const fixture = getToolcraftPerformanceStressValue(config, scenarioId);
|
|
452
|
+
|
|
453
|
+
if (!isToolcraftStressFixtureObject(fixture)) {
|
|
454
|
+
throw new Error(
|
|
455
|
+
`Toolcraft performance scenario "${scenarioId}" must provide an object stressFixture.value for combined fixture application.`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const fixtureKeys = Object.keys(fixture);
|
|
460
|
+
if (fixtureKeys.length === 0) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
`Toolcraft performance scenario "${scenarioId}" stressFixture.value must contain at least one key.`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const missingKeys = fixtureKeys.filter((key) => !appliers[key]);
|
|
467
|
+
if (missingKeys.length > 0) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`Toolcraft performance scenario "${scenarioId}" is missing fixture appliers for: ${missingKeys.join(
|
|
470
|
+
", ",
|
|
471
|
+
)}.`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const extraKeys = Object.keys(appliers).filter((key) => !fixtureKeys.includes(key));
|
|
476
|
+
if (extraKeys.length > 0) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`Toolcraft performance scenario "${scenarioId}" declares fixture appliers not present in stressFixture.value: ${extraKeys.join(
|
|
479
|
+
", ",
|
|
480
|
+
)}.`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const key of fixtureKeys) {
|
|
485
|
+
await appliers[key]!(fixture[key], {
|
|
486
|
+
config,
|
|
487
|
+
fixture,
|
|
488
|
+
key,
|
|
489
|
+
page,
|
|
490
|
+
scenarioId,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return fixture;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function applyToolcraftPerformanceWorkloadFixture(
|
|
498
|
+
page: Page,
|
|
499
|
+
config: ToolcraftPerformanceConfig,
|
|
500
|
+
scenarioId: string,
|
|
501
|
+
appliers: ToolcraftStressFixtureAppliers,
|
|
502
|
+
): Promise<Record<string, unknown>> {
|
|
503
|
+
const fixture = getToolcraftPerformanceWorkloadValue(config, scenarioId);
|
|
504
|
+
|
|
505
|
+
if (!isToolcraftStressFixtureObject(fixture)) {
|
|
506
|
+
throw new Error(
|
|
507
|
+
`Toolcraft performance scenario "${scenarioId}" must provide an object workloadFixture.value for baseline fixture application.`,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const fixtureKeys = Object.keys(fixture);
|
|
512
|
+
if (fixtureKeys.length === 0) {
|
|
513
|
+
throw new Error(
|
|
514
|
+
`Toolcraft performance scenario "${scenarioId}" workloadFixture.value must contain at least one key.`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const missingKeys = fixtureKeys.filter((key) => !appliers[key]);
|
|
519
|
+
if (missingKeys.length > 0) {
|
|
520
|
+
throw new Error(
|
|
521
|
+
`Toolcraft performance scenario "${scenarioId}" is missing workload fixture appliers for: ${missingKeys.join(
|
|
522
|
+
", ",
|
|
523
|
+
)}.`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const extraKeys = Object.keys(appliers).filter((key) => !fixtureKeys.includes(key));
|
|
528
|
+
if (extraKeys.length > 0) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`Toolcraft performance scenario "${scenarioId}" declares workload fixture appliers not present in workloadFixture.value: ${extraKeys.join(
|
|
531
|
+
", ",
|
|
532
|
+
)}.`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
for (const key of fixtureKeys) {
|
|
537
|
+
await appliers[key]!(fixture[key], {
|
|
538
|
+
config,
|
|
539
|
+
fixture,
|
|
540
|
+
key,
|
|
541
|
+
page,
|
|
542
|
+
scenarioId,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return fixture;
|
|
547
|
+
}
|
|
548
|
+
|
|
374
549
|
export async function dragToolcraftCanvasViewport(
|
|
375
550
|
page: Page,
|
|
376
551
|
delta: { x: number; y: number } = { x: 96, y: -64 },
|
|
@@ -553,6 +728,28 @@ export function getToolcraftPerformanceStressValue<TValue = unknown>(
|
|
|
553
728
|
return scenario.stressFixture.value as TValue;
|
|
554
729
|
}
|
|
555
730
|
|
|
731
|
+
export function getToolcraftPerformanceWorkloadValue<TValue = unknown>(
|
|
732
|
+
config: ToolcraftPerformanceConfig,
|
|
733
|
+
scenarioId: string,
|
|
734
|
+
): TValue {
|
|
735
|
+
const scenario = config.scenarios.find((item) => item.id === scenarioId);
|
|
736
|
+
|
|
737
|
+
if (!scenario) {
|
|
738
|
+
throw new Error(`Toolcraft performance scenario "${scenarioId}" was not found.`);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (
|
|
742
|
+
!scenario.workloadFixture ||
|
|
743
|
+
!Object.prototype.hasOwnProperty.call(scenario.workloadFixture, "value")
|
|
744
|
+
) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`Toolcraft performance scenario "${scenarioId}" does not declare workloadFixture.value.`,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return scenario.workloadFixture.value as TValue;
|
|
751
|
+
}
|
|
752
|
+
|
|
556
753
|
export function expectToolcraftScenarioPerformanceBudget(
|
|
557
754
|
result: ToolcraftPerformanceBudgetResult,
|
|
558
755
|
config: ToolcraftPerformanceConfig,
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"ai:check": "node scripts/check-ai-skills.mjs",
|
|
8
8
|
"dev": "node scripts/run-vite-on-free-port.mjs dev",
|
|
9
|
+
"dev:restart": "node scripts/run-vite-on-free-port.mjs dev --toolcraft-restart",
|
|
9
10
|
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
|
10
11
|
"preview": "node scripts/run-vite-on-free-port.mjs preview",
|
|
12
|
+
"preview:restart": "node scripts/run-vite-on-free-port.mjs preview --toolcraft-restart",
|
|
11
13
|
"docs:check": "node scripts/check-toolcraft-docs.mjs",
|
|
12
14
|
"test": "node scripts/check-toolcraft-docs.mjs && node scripts/check-toolcraft-integrity.mjs && node --test scripts/*.test.mjs && vitest run src --passWithNoTests",
|
|
13
15
|
"test:browser": "playwright install chromium && playwright test",
|
|
@@ -20,6 +22,9 @@
|
|
|
20
22
|
},
|
|
21
23
|
"dependencies": {
|
|
22
24
|
"@base-ui/react": "^1.4.1",
|
|
25
|
+
"@dnd-kit/core": "^6.3.1",
|
|
26
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
27
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
23
28
|
"@fontsource-variable/inter": "^5.2.8",
|
|
24
29
|
"@phosphor-icons/react": "^2.1.10",
|
|
25
30
|
"@repo/toolcraft-runtime": "workspace:*",
|
|
@@ -2,21 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
findAvailablePort,
|
|
7
|
+
isPortAvailableOnLoopback,
|
|
8
|
+
killListeningProcessesOnPort,
|
|
9
|
+
readPreferredPort,
|
|
10
|
+
readSavedToolcraftPort,
|
|
11
|
+
waitForPortAvailable,
|
|
12
|
+
writeSavedToolcraftPort,
|
|
13
|
+
} from "./toolcraft-port.mjs";
|
|
6
14
|
|
|
7
15
|
const viteCommand = process.argv[2] ?? "dev";
|
|
8
|
-
const
|
|
16
|
+
const restartArg = "--toolcraft-restart";
|
|
17
|
+
const rawPassthroughArgs = process.argv.slice(3).filter((arg) => arg !== "--");
|
|
18
|
+
const restartSamePort =
|
|
19
|
+
process.env.TOOLCRAFT_RESTART === "1" || rawPassthroughArgs.includes(restartArg);
|
|
20
|
+
const passthroughArgs = rawPassthroughArgs.filter((arg) => arg !== restartArg);
|
|
9
21
|
const preferredPort = readPreferredPort([
|
|
10
22
|
"TOOLCRAFT_DEV_PORT",
|
|
11
23
|
"TOOLCRAFT_PORT",
|
|
12
24
|
"PORT",
|
|
13
25
|
]);
|
|
14
|
-
const
|
|
26
|
+
const savedPort = restartSamePort ? await readSavedToolcraftPort() : null;
|
|
27
|
+
const port = savedPort ?? (await findAvailablePort(preferredPort));
|
|
15
28
|
|
|
16
|
-
if (
|
|
29
|
+
if (savedPort) {
|
|
30
|
+
console.log(`[toolcraft] Restart mode: reusing port ${savedPort}.`);
|
|
31
|
+
|
|
32
|
+
if (!(await isPortAvailableOnLoopback(savedPort))) {
|
|
33
|
+
console.log(`[toolcraft] Port ${savedPort} is busy; stopping the existing listener first.`);
|
|
34
|
+
await killListeningProcessesOnPort(savedPort);
|
|
35
|
+
|
|
36
|
+
if (!(await waitForPortAvailable(savedPort))) {
|
|
37
|
+
console.log(`[toolcraft] Port ${savedPort} is still busy; forcing listener shutdown.`);
|
|
38
|
+
await killListeningProcessesOnPort(savedPort, { signal: "SIGKILL" });
|
|
39
|
+
|
|
40
|
+
if (!(await waitForPortAvailable(savedPort, { timeoutMs: 1_000 }))) {
|
|
41
|
+
throw new Error(`Port ${savedPort} is still busy after forced restart cleanup.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} else if (restartSamePort) {
|
|
46
|
+
console.log("[toolcraft] Restart mode requested, but no saved port exists yet.");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!savedPort && port !== preferredPort) {
|
|
17
50
|
console.log(`[toolcraft] Port ${preferredPort} is busy; using ${port} instead.`);
|
|
18
51
|
}
|
|
19
52
|
|
|
53
|
+
await writeSavedToolcraftPort(port);
|
|
54
|
+
|
|
20
55
|
const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
|
21
56
|
const child = spawn(pnpmCommand, ["exec", "vite", viteCommand, "--port", String(port), ...passthroughArgs], {
|
|
22
57
|
env: {
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
1
3
|
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
2
6
|
|
|
3
7
|
export const DEFAULT_TOOLCRAFT_PORT = 3002;
|
|
4
8
|
const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const PORT_STATE_DIR = ".toolcraft";
|
|
11
|
+
const PORT_STATE_FILE = "server-port.json";
|
|
5
12
|
|
|
6
13
|
export function readPreferredPort(names, fallback = DEFAULT_TOOLCRAFT_PORT, env = process.env) {
|
|
7
14
|
for (const name of names) {
|
|
@@ -52,3 +59,98 @@ export async function findAvailablePort(startPort = DEFAULT_TOOLCRAFT_PORT) {
|
|
|
52
59
|
|
|
53
60
|
throw new Error(`No free port found at or above ${startPort}.`);
|
|
54
61
|
}
|
|
62
|
+
|
|
63
|
+
function isValidPort(port) {
|
|
64
|
+
return Number.isInteger(port) && port > 0 && port <= 65_535;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getToolcraftPortStatePath(cwd = process.cwd()) {
|
|
68
|
+
return path.join(cwd, PORT_STATE_DIR, PORT_STATE_FILE);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function readSavedToolcraftPort(cwd = process.cwd()) {
|
|
72
|
+
try {
|
|
73
|
+
const source = await fs.readFile(getToolcraftPortStatePath(cwd), "utf8");
|
|
74
|
+
const state = JSON.parse(source);
|
|
75
|
+
|
|
76
|
+
return isValidPort(state.port) ? state.port : null;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error?.code === "ENOENT" || error instanceof SyntaxError) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function writeSavedToolcraftPort(port, cwd = process.cwd()) {
|
|
87
|
+
if (!isValidPort(port)) {
|
|
88
|
+
throw new Error(`Cannot save invalid Toolcraft port: ${port}.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const statePath = getToolcraftPortStatePath(cwd);
|
|
92
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
93
|
+
await fs.writeFile(
|
|
94
|
+
statePath,
|
|
95
|
+
`${JSON.stringify({ port, updatedAt: new Date().toISOString() }, null, 2)}\n`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function getListeningProcessIds(port, execFileImpl = execFileAsync) {
|
|
100
|
+
if (!isValidPort(port)) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const { stdout } =
|
|
106
|
+
process.platform === "win32"
|
|
107
|
+
? await execFileImpl("powershell", [
|
|
108
|
+
"-NoProfile",
|
|
109
|
+
"-Command",
|
|
110
|
+
`Get-NetTCPConnection -LocalPort ${port} -State Listen | Select-Object -ExpandProperty OwningProcess -Unique`,
|
|
111
|
+
])
|
|
112
|
+
: await execFileImpl("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
|
|
113
|
+
|
|
114
|
+
return [...new Set(String(stdout).split(/\s+/).map(Number).filter(Number.isInteger))].filter(
|
|
115
|
+
(pid) => pid > 0 && pid !== process.pid,
|
|
116
|
+
);
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function killListeningProcessesOnPort(port, options = {}) {
|
|
123
|
+
const {
|
|
124
|
+
execFileImpl = execFileAsync,
|
|
125
|
+
killProcess = process.kill,
|
|
126
|
+
signal = "SIGTERM",
|
|
127
|
+
} = options;
|
|
128
|
+
const pids = await getListeningProcessIds(port, execFileImpl);
|
|
129
|
+
|
|
130
|
+
for (const pid of pids) {
|
|
131
|
+
try {
|
|
132
|
+
killProcess(pid, signal);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error?.code !== "ESRCH") {
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return pids;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function waitForPortAvailable(port, options = {}) {
|
|
144
|
+
const { intervalMs = 100, timeoutMs = 3_000 } = options;
|
|
145
|
+
const deadline = Date.now() + timeoutMs;
|
|
146
|
+
|
|
147
|
+
while (Date.now() <= deadline) {
|
|
148
|
+
if (await isPortAvailableOnLoopback(port)) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
2
3
|
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
3
6
|
import test from "node:test";
|
|
4
7
|
|
|
5
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
findAvailablePort,
|
|
10
|
+
getListeningProcessIds,
|
|
11
|
+
killListeningProcessesOnPort,
|
|
12
|
+
readSavedToolcraftPort,
|
|
13
|
+
writeSavedToolcraftPort,
|
|
14
|
+
} from "./toolcraft-port.mjs";
|
|
6
15
|
|
|
7
16
|
function listen(host) {
|
|
8
17
|
return new Promise((resolve, reject) => {
|
|
@@ -71,3 +80,53 @@ test("findAvailablePort skips ports occupied on IPv4 localhost", async (t) => {
|
|
|
71
80
|
"A port occupied on 127.0.0.1 must be treated as unavailable for localhost URLs.",
|
|
72
81
|
);
|
|
73
82
|
});
|
|
83
|
+
|
|
84
|
+
test("saved Toolcraft port round-trips through local project state", async (t) => {
|
|
85
|
+
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "toolcraft-port-"));
|
|
86
|
+
t.after(() => fs.rm(cwd, { force: true, recursive: true }));
|
|
87
|
+
|
|
88
|
+
assert.equal(await readSavedToolcraftPort(cwd), null);
|
|
89
|
+
|
|
90
|
+
await writeSavedToolcraftPort(4123, cwd);
|
|
91
|
+
|
|
92
|
+
assert.equal(await readSavedToolcraftPort(cwd), 4123);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("getListeningProcessIds parses unique listening process ids", async () => {
|
|
96
|
+
const pids = await getListeningProcessIds(4123, async () => ({
|
|
97
|
+
stderr: "",
|
|
98
|
+
stdout: "123\n456\n123\nnot-a-pid\n",
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(pids, [123, 456]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("killListeningProcessesOnPort targets listeners found on the port", async () => {
|
|
105
|
+
const killed = [];
|
|
106
|
+
const pids = await killListeningProcessesOnPort(4123, {
|
|
107
|
+
execFileImpl: async () => ({ stderr: "", stdout: "123\n456\n" }),
|
|
108
|
+
killProcess: (pid, signal) => {
|
|
109
|
+
killed.push({ pid, signal });
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
assert.deepEqual(pids, [123, 456]);
|
|
114
|
+
assert.deepEqual(killed, [
|
|
115
|
+
{ pid: 123, signal: "SIGTERM" },
|
|
116
|
+
{ pid: 456, signal: "SIGTERM" },
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("killListeningProcessesOnPort supports forced restart cleanup", async () => {
|
|
121
|
+
const killed = [];
|
|
122
|
+
const pids = await killListeningProcessesOnPort(4123, {
|
|
123
|
+
execFileImpl: async () => ({ stderr: "", stdout: "789\n" }),
|
|
124
|
+
killProcess: (pid, signal) => {
|
|
125
|
+
killed.push({ pid, signal });
|
|
126
|
+
},
|
|
127
|
+
signal: "SIGKILL",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
assert.deepEqual(pids, [789]);
|
|
131
|
+
assert.deepEqual(killed, [{ pid: 789, signal: "SIGKILL" }]);
|
|
132
|
+
});
|