@codeyam-editor/codeyam-editor 0.1.8 → 0.1.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/npm/cloud.js +361 -18
- package/npm/editor-dev.js +24 -2
- package/npm/editor.js +15 -5
- package/npm/fleet-add-failure.js +355 -21
- package/npm/fleet-branch.js +124 -1
- package/npm/fleet-build-data.js +12 -1
- package/npm/fleet-cleanup.js +84 -3
- package/npm/fleet-datastore-health.js +107 -0
- package/npm/fleet-revision.js +108 -0
- package/npm/fleet-run-setup.js +66 -0
- package/npm/fleet-session-activity.js +162 -0
- package/npm/fleet-vm-readiness.js +57 -16
- package/npm/launch-config.js +126 -2
- package/npm/scenario-check.js +697 -34
- package/npm/scenario-interactivity.js +477 -23
- package/npm/scenario-issues.js +61 -0
- package/npm/scenario-playwright.js +67 -14
- package/npm/utils.js +262 -22
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-DSVZvEkB.js → ContainerInspector-C7I9wctz.js} +1 -1
- package/ui/dist/assets/EditorShell-9aYMeOdZ.js +330 -0
- package/ui/dist/assets/EditorShell-CE8iG_EM.css +1 -0
- package/ui/dist/assets/ScenarioDataPanel-CLwkrex0.js +18 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-Cyk6efiI.js → ScenarioDataPanelFullPage-DT0TZC4M.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-Bsarhcrl.js +21 -0
- package/ui/dist/assets/index-BRoAenc1.css +1 -0
- package/ui/dist/assets/{index-BPLKgwon.js → index-iMR5siiG.js} +9 -9
- package/ui/dist/index.html +2 -2
- package/ui/dist/preview-subdir-demo/index.html +62 -0
- package/ui/dist/preview-subdir-demo/sibling.html +48 -0
- package/ui/dist/assets/EditorShell-DnXdHrar.js +0 -256
- package/ui/dist/assets/EditorShell-FvbXyqqD.css +0 -1
- package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +0 -15
- package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +0 -21
- package/ui/dist/assets/index-DKpgJLo3.css +0 -1
package/npm/scenario-issues.js
CHANGED
|
@@ -7,6 +7,19 @@ function createIssue(kind, message, extra = {}) {
|
|
|
7
7
|
};
|
|
8
8
|
if (extra.matchedPattern != null) issue.matchedPattern = extra.matchedPattern;
|
|
9
9
|
if (extra.contextSnippet != null) issue.contextSnippet = extra.contextSnippet;
|
|
10
|
+
// The hydration gate's cross-origin ATTRIBUTION, carried as data rather than
|
|
11
|
+
// only as prose. A consumer that must act on the verdict — escalating the
|
|
12
|
+
// preview off the subpath when the counterpart origin hydrates — would
|
|
13
|
+
// otherwise have to regex the English message, which breaks the moment the
|
|
14
|
+
// wording is improved. Same conditional shape as the two fields above so an
|
|
15
|
+
// issue that carries no attribution serializes exactly as it did before.
|
|
16
|
+
if (extra.crossOrigin != null) issue.crossOrigin = extra.crossOrigin;
|
|
17
|
+
if (extra.counterpartUrl != null) issue.counterpartUrl = extra.counterpartUrl;
|
|
18
|
+
// The dev-server-down verdict, carried as data for the same reason as the
|
|
19
|
+
// attribution fields above: a consumer that must distinguish "the app served
|
|
20
|
+
// an error" from "the app was not serving at all" should branch on a field,
|
|
21
|
+
// not on the prose of the message.
|
|
22
|
+
if (extra.devServerDown != null) issue.devServerDown = extra.devServerDown;
|
|
10
23
|
return issue;
|
|
11
24
|
}
|
|
12
25
|
|
|
@@ -17,6 +30,42 @@ function pushIssue(issues, issue) {
|
|
|
17
30
|
}
|
|
18
31
|
}
|
|
19
32
|
|
|
33
|
+
// Derive which origin a capture actually loaded from, and whether that differs
|
|
34
|
+
// from the origin the VIEWER's browser is served.
|
|
35
|
+
//
|
|
36
|
+
// The two can legitimately differ: the capture browser runs where the app is,
|
|
37
|
+
// the viewer may not, and aiming them independently is what makes a capture
|
|
38
|
+
// work on a cloud VM at all. A capture that renders perfectly from the app's
|
|
39
|
+
// own origin while the user's pane sits on `/__codeyam_preview` is a USEFUL
|
|
40
|
+
// result — but only if the difference travels with it instead of having to be
|
|
41
|
+
// inferred from a URL the reader must parse themselves.
|
|
42
|
+
//
|
|
43
|
+
// Both unknowns resolve toward "no split", never toward a guess. A caller that
|
|
44
|
+
// supplies no `viewerOrigin` (every pre-existing call site) and a `url` with no
|
|
45
|
+
// parseable origin both yield `false`, so no existing consumer sees a changed
|
|
46
|
+
// verdict and an unclassifiable capture is never reported as diverging.
|
|
47
|
+
//
|
|
48
|
+
// Deliberately not reusing `isCrossOriginRequest` from scenario-check.js: that
|
|
49
|
+
// module imports THIS one, so depending on it would invert the dependency, and
|
|
50
|
+
// its fail-safe answers a different question (never strip markers) than this
|
|
51
|
+
// one does (never claim a split).
|
|
52
|
+
function deriveOriginSplit(url, viewerOrigin) {
|
|
53
|
+
let captureOrigin = null;
|
|
54
|
+
try {
|
|
55
|
+
captureOrigin = new URL(url).origin;
|
|
56
|
+
} catch (_) {
|
|
57
|
+
/* a malformed or relative capture URL has no origin to report */
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
captureOrigin,
|
|
61
|
+
viewerOrigin: viewerOrigin ?? null,
|
|
62
|
+
captureOriginDiffersFromViewer:
|
|
63
|
+
captureOrigin != null &&
|
|
64
|
+
viewerOrigin != null &&
|
|
65
|
+
captureOrigin !== viewerOrigin,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
20
69
|
function buildResult({
|
|
21
70
|
loaded,
|
|
22
71
|
hasContent,
|
|
@@ -26,7 +75,13 @@ function buildResult({
|
|
|
26
75
|
unmockedRoutes = [],
|
|
27
76
|
mockUsage = { used: [], unused: [] },
|
|
28
77
|
externalRequests = [],
|
|
78
|
+
viewerOrigin = null,
|
|
29
79
|
}) {
|
|
80
|
+
const { captureOrigin, captureOriginDiffersFromViewer } = deriveOriginSplit(
|
|
81
|
+
url,
|
|
82
|
+
viewerOrigin,
|
|
83
|
+
);
|
|
84
|
+
|
|
30
85
|
return {
|
|
31
86
|
ok: loaded && hasContent && issues.length === 0,
|
|
32
87
|
loaded,
|
|
@@ -48,6 +103,11 @@ function buildResult({
|
|
|
48
103
|
// Requests grouped by origin, split mocked/unmocked and same/cross-origin.
|
|
49
104
|
// The line that ends a "why is this page blank" misdiagnosis.
|
|
50
105
|
externalRequests,
|
|
106
|
+
// The two hops, named. See `deriveOriginSplit` for why they can differ and
|
|
107
|
+
// why reporting the difference is not optional.
|
|
108
|
+
captureOrigin,
|
|
109
|
+
viewerOrigin,
|
|
110
|
+
captureOriginDiffersFromViewer,
|
|
51
111
|
};
|
|
52
112
|
}
|
|
53
113
|
|
|
@@ -55,4 +115,5 @@ module.exports = {
|
|
|
55
115
|
createIssue,
|
|
56
116
|
pushIssue,
|
|
57
117
|
buildResult,
|
|
118
|
+
deriveOriginSplit,
|
|
58
119
|
};
|
|
@@ -928,6 +928,59 @@ async function collectInteractiveLabels(frame) {
|
|
|
928
928
|
}, INTERACTIVE_SELECTOR);
|
|
929
929
|
}
|
|
930
930
|
|
|
931
|
+
// How many candidate labels an error message may echo. Deliberately tighter
|
|
932
|
+
// than `collectInteractiveLabels`' own 20-label read cap: the labels are the
|
|
933
|
+
// USER'S page content, so every one printed is a line of their app leaked into
|
|
934
|
+
// the terminal. Enough to answer "did you mean one of these?", not enough to
|
|
935
|
+
// transcribe the page.
|
|
936
|
+
const CANDIDATE_LABEL_CAP = 8;
|
|
937
|
+
|
|
938
|
+
// Render the candidate labels for an error message, capped and counted. Shared
|
|
939
|
+
// by the no-match and action-failed paths so the echo policy lives in one place.
|
|
940
|
+
async function candidateLabelSummary(frame) {
|
|
941
|
+
const candidates = await collectInteractiveLabels(frame);
|
|
942
|
+
if (candidates.length === 0) return "(none found on page)";
|
|
943
|
+
const shown = candidates.slice(0, CANDIDATE_LABEL_CAP);
|
|
944
|
+
const rest = candidates.length - shown.length;
|
|
945
|
+
return rest > 0 ? `${shown.join(", ")} (+${rest} more)` : shown.join(", ");
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Compose the "no element matched" error, branched on the form the caller
|
|
949
|
+
// ACTUALLY passed. Pure and string-returning so both branches are asserted
|
|
950
|
+
// without a browser.
|
|
951
|
+
//
|
|
952
|
+
// The substring advice ("prefer an exact CSS selector") only applies to a text
|
|
953
|
+
// label. Printing it at a caller who already passed `#id` contradicts its own
|
|
954
|
+
// input and reads as misdirection — the failure mode that sent two sessions
|
|
955
|
+
// hunting a selector bug when the page had simply not hydrated. The candidate
|
|
956
|
+
// labels ride with the text branch for the same reason: they answer "did you
|
|
957
|
+
// mean one of these?", a question only a text match can have asked, and every
|
|
958
|
+
// label printed is a line of the user's own page echoed into the terminal.
|
|
959
|
+
function noMatchGuidance({ targetDesc, matchedByText, candidateLabels }) {
|
|
960
|
+
const queryParamHint =
|
|
961
|
+
`Many filter/status/sort states are also reachable directly via a ` +
|
|
962
|
+
`URL query param (e.g. add "?status=active" to the path) with no ` +
|
|
963
|
+
`interaction at all.`;
|
|
964
|
+
if (matchedByText) {
|
|
965
|
+
return (
|
|
966
|
+
`preview-interact: no element matched ${targetDesc}. ` +
|
|
967
|
+
`Text is matched as a case-insensitive SUBSTRING, so a misspelled or ` +
|
|
968
|
+
`over-specific label matches nothing — prefer an exact/role/testid CSS ` +
|
|
969
|
+
`selector (e.g. {"selector":"[data-testid=\\"save\\"]"}) for a precise ` +
|
|
970
|
+
`target. ${queryParamHint} ` +
|
|
971
|
+
`Candidate interactive labels: ${candidateLabels}`
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
return (
|
|
975
|
+
`preview-interact: no element matched ${targetDesc}. ` +
|
|
976
|
+
`That is already an exact CSS selector, so nothing on the page matches ` +
|
|
977
|
+
`it as written — confirm the element is present in THIS state (it may be ` +
|
|
978
|
+
`behind a tab, dialog, or conditional render), that the id/attribute is ` +
|
|
979
|
+
`spelled as the markup emits it, and that the page rendered and hydrated ` +
|
|
980
|
+
`at all. ${queryParamHint}`
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
|
|
931
984
|
// Describe the elements a substring text match resolved, so an ambiguity
|
|
932
985
|
// warning can name the competing controls (e.g. the preset button "Bet" vs the
|
|
933
986
|
// disclosure button "…or bet on"). Reads each matched element's inner text via
|
|
@@ -1075,17 +1128,17 @@ async function performInteraction(
|
|
|
1075
1128
|
}
|
|
1076
1129
|
|
|
1077
1130
|
if (matchCount === 0) {
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1131
|
+
// Candidate labels are collected ONLY for the text branch — see
|
|
1132
|
+
// `noMatchGuidance` for why a CSS-selector miss neither needs them nor
|
|
1133
|
+
// should echo the user's page content back at them.
|
|
1081
1134
|
throw new Error(
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1135
|
+
noMatchGuidance({
|
|
1136
|
+
targetDesc,
|
|
1137
|
+
matchedByText,
|
|
1138
|
+
candidateLabels: matchedByText
|
|
1139
|
+
? await candidateLabelSummary(frame)
|
|
1140
|
+
: null,
|
|
1141
|
+
}),
|
|
1089
1142
|
);
|
|
1090
1143
|
}
|
|
1091
1144
|
|
|
@@ -1127,12 +1180,9 @@ async function performInteraction(
|
|
|
1127
1180
|
);
|
|
1128
1181
|
}
|
|
1129
1182
|
} catch (error) {
|
|
1130
|
-
const candidates = await collectInteractiveLabels(frame);
|
|
1131
|
-
const candidateList =
|
|
1132
|
-
candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
|
|
1133
1183
|
throw new Error(
|
|
1134
1184
|
`preview-interact: action "${action}" failed against ${targetDesc}: ${error.message || String(error)}. ` +
|
|
1135
|
-
`Candidate interactive labels: ${
|
|
1185
|
+
`Candidate interactive labels: ${await candidateLabelSummary(frame)}`,
|
|
1136
1186
|
);
|
|
1137
1187
|
}
|
|
1138
1188
|
}
|
|
@@ -1209,6 +1259,7 @@ async function performInteractionSequence(
|
|
|
1209
1259
|
|
|
1210
1260
|
module.exports = {
|
|
1211
1261
|
logCaptureTiming,
|
|
1262
|
+
defaultReadServerState,
|
|
1212
1263
|
resolveTcpTarget,
|
|
1213
1264
|
assertAppPortReachable,
|
|
1214
1265
|
escapeHtmlAttribute,
|
|
@@ -1230,6 +1281,8 @@ module.exports = {
|
|
|
1230
1281
|
loadScenarioInIframe,
|
|
1231
1282
|
loadScenarioTopLevel,
|
|
1232
1283
|
collectInteractiveLabels,
|
|
1284
|
+
candidateLabelSummary,
|
|
1285
|
+
noMatchGuidance,
|
|
1233
1286
|
describeMatchCandidates,
|
|
1234
1287
|
performInteraction,
|
|
1235
1288
|
waitForPredicate,
|
package/npm/utils.js
CHANGED
|
@@ -5,7 +5,23 @@ const fs = require("fs");
|
|
|
5
5
|
const readline = require("readline");
|
|
6
6
|
|
|
7
7
|
const rootDir = path.resolve(__dirname, "..");
|
|
8
|
-
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Path to the server-state file for `projectDir`.
|
|
11
|
+
*
|
|
12
|
+
* The editor server writes its state into the PROJECT it serves, not into the
|
|
13
|
+
* editor repo — so this must be resolved against the target project. It was
|
|
14
|
+
* previously a module-level const bound to `rootDir`, which made
|
|
15
|
+
* `readServerState` return `null` for every external-project launch and left
|
|
16
|
+
* the pid-based recovery in `stopServer` dead in exactly the configuration
|
|
17
|
+
* `codeyam-editor-dev` exists to serve.
|
|
18
|
+
*
|
|
19
|
+
* Defaults to `process.cwd()` because every in-repo caller already runs from
|
|
20
|
+
* the project root; external-project callers must pass their `projectDir`.
|
|
21
|
+
*/
|
|
22
|
+
function serverStatePathFor(projectDir = process.cwd()) {
|
|
23
|
+
return path.join(projectDir, ".codeyam", "server-state.json");
|
|
24
|
+
}
|
|
9
25
|
|
|
10
26
|
/**
|
|
11
27
|
* Map (process.platform, process.arch) → the platform sub-package that
|
|
@@ -459,6 +475,56 @@ function resolveWrappedAppPort(merged, envValue, fallback) {
|
|
|
459
475
|
return resolveLauncherPort(envValue, mergedValue, fallback);
|
|
460
476
|
}
|
|
461
477
|
|
|
478
|
+
/** True for a value usable as a TCP port: an integer in 1..=65535. */
|
|
479
|
+
function isUsablePort(value) {
|
|
480
|
+
return Number.isInteger(value) && value > 0 && value <= 65535;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Resolve the control port a RUNNING server for `projectDir` is actually on.
|
|
485
|
+
*
|
|
486
|
+
* Mirrors `resolve_control_port` in
|
|
487
|
+
* `crates/codeyam-editor/src/commands/editor/control_port.rs`:
|
|
488
|
+
* `server-state.json` > `CODEYAM_CONTROL_PORT` > the config default. The
|
|
489
|
+
* editor server runs on a dynamic port whenever the Project Launcher or the
|
|
490
|
+
* desktop supervisor started it, and `editor.json`'s `proxy.controlPort` is
|
|
491
|
+
* only the static default — so `server-state.json` is the only reliable
|
|
492
|
+
* discriminator. The npm launchers used the INVERSE (config only, state file
|
|
493
|
+
* never consulted), which is why a `--restart` could target a port nothing was
|
|
494
|
+
* on while the live server sat on another.
|
|
495
|
+
*
|
|
496
|
+
* This resolves the port to SEARCH for, never the port to bind: the launcher
|
|
497
|
+
* deliberately binds the editor repo's own control port (see the comment at
|
|
498
|
+
* the `SERVER_PORT` resolution in `editor-dev.js`).
|
|
499
|
+
*/
|
|
500
|
+
function resolveRunningControlPort(projectDir = process.cwd(), configPort) {
|
|
501
|
+
const state = readServerState(projectDir);
|
|
502
|
+
if (state && isUsablePort(state.controlPort)) return state.controlPort;
|
|
503
|
+
|
|
504
|
+
const envValue = process.env.CODEYAM_CONTROL_PORT;
|
|
505
|
+
if (envValue !== undefined && envValue !== null && String(envValue).trim() !== "") {
|
|
506
|
+
const parsed = parseInt(String(envValue).trim(), 10);
|
|
507
|
+
if (isUsablePort(parsed)) return parsed;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return configPort;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Resolve the proxy HTTP port the server will bind for `projectDir`.
|
|
515
|
+
*
|
|
516
|
+
* Mirrors `resolve_proxy_http_port` in
|
|
517
|
+
* `crates/codeyam-editor/src/commands/start.rs`: `CODEYAM_PROXY_HTTP_PORT` >
|
|
518
|
+
* `proxy.httpPort` > 14100 (`default_http_port` in
|
|
519
|
+
* `crates/types/src/config.rs`). The start path binds this address
|
|
520
|
+
* unconditionally, so it is the port a second server actually collides on —
|
|
521
|
+
* the resource the control-port classification never looked at.
|
|
522
|
+
*/
|
|
523
|
+
function resolveProxyHttpPort(merged, envValue, fallback = 14100) {
|
|
524
|
+
const configured = merged && merged.proxy && merged.proxy.httpPort;
|
|
525
|
+
return resolveLauncherPort(envValue, configured, fallback);
|
|
526
|
+
}
|
|
527
|
+
|
|
462
528
|
/** Try to connect to a specific host:port. Returns true if something is listening. */
|
|
463
529
|
function tryConnect(port, host) {
|
|
464
530
|
return new Promise((resolve) => {
|
|
@@ -502,17 +568,17 @@ async function waitForPortToClose(port, timeoutMs = 10000, intervalMs = 200) {
|
|
|
502
568
|
return false;
|
|
503
569
|
}
|
|
504
570
|
|
|
505
|
-
function readServerState() {
|
|
571
|
+
function readServerState(projectDir = process.cwd()) {
|
|
506
572
|
try {
|
|
507
|
-
return JSON.parse(fs.readFileSync(
|
|
573
|
+
return JSON.parse(fs.readFileSync(serverStatePathFor(projectDir), "utf8"));
|
|
508
574
|
} catch {
|
|
509
575
|
return null;
|
|
510
576
|
}
|
|
511
577
|
}
|
|
512
578
|
|
|
513
|
-
function clearServerState() {
|
|
579
|
+
function clearServerState(projectDir = process.cwd()) {
|
|
514
580
|
try {
|
|
515
|
-
fs.unlinkSync(
|
|
581
|
+
fs.unlinkSync(serverStatePathFor(projectDir));
|
|
516
582
|
} catch {}
|
|
517
583
|
}
|
|
518
584
|
|
|
@@ -626,6 +692,100 @@ function formatCrossProjectError(port, holder, verb) {
|
|
|
626
692
|
);
|
|
627
693
|
}
|
|
628
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Build the "the proxy port this server must bind is already taken" error
|
|
697
|
+
* string. Lives next to the other `format*Error` builders so the wording is
|
|
698
|
+
* unit-testable without a running server. Names the port, the pid holding it,
|
|
699
|
+
* and the config key to change — the three things the old failure mode made
|
|
700
|
+
* the user dig out of `.codeyam/logs/editor-server.out.log` by hand.
|
|
701
|
+
*/
|
|
702
|
+
function formatProxyPortConflictError(httpPort, pids = []) {
|
|
703
|
+
const pidNote = pids.length > 0 ? ` (pid ${pids.join(", ")})` : "";
|
|
704
|
+
return (
|
|
705
|
+
`Port ${httpPort} is already in use${pidNote}, and the codeyam-editor ` +
|
|
706
|
+
`server binds it at startup — spawning now would fail with EADDRINUSE ` +
|
|
707
|
+
`and leave nothing listening.\n` +
|
|
708
|
+
`\n` +
|
|
709
|
+
`Free that port, or change httpPort in .codeyam/editor.local.json, e.g.:\n` +
|
|
710
|
+
` codeyam-editor editor config-override proxy.httpPort ${httpPort + 100}`
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Make sure the proxy HTTP port is free before a spawn that would bind it.
|
|
716
|
+
*
|
|
717
|
+
* The proxy port — not the control port — is the resource that actually
|
|
718
|
+
* conflicts: two servers for the same project legitimately resolve DIFFERENT
|
|
719
|
+
* control ports (the npm wrappers use the editor repo's config, the Rust binary
|
|
720
|
+
* uses the project's own), but both bind the project's `proxy.httpPort`. So a
|
|
721
|
+
* control-port classification can report "free" while the spawn is already
|
|
722
|
+
* doomed, which is exactly how a failed restart came to look like a successful
|
|
723
|
+
* one.
|
|
724
|
+
*
|
|
725
|
+
* Ownership is decided on the control port named by the project's own
|
|
726
|
+
* `server-state.json`, never by probing the identity endpoint through the proxy
|
|
727
|
+
* port itself: the proxy forwards to the wrapped app, so a probe there reports
|
|
728
|
+
* whatever the app answers and would misclassify this project's own server as
|
|
729
|
+
* foreign. Teardown likewise goes through the control port, where the shutdown
|
|
730
|
+
* endpoint lives.
|
|
731
|
+
*
|
|
732
|
+
* Returns `{ kind: "free" }`, `{ kind: "cleared" }` (a stale same-project server
|
|
733
|
+
* was stopped), or `{ kind: "conflict", pids }` — the caller owns the policy.
|
|
734
|
+
* Effects are injected so this is testable without a live server.
|
|
735
|
+
*/
|
|
736
|
+
async function ensureProxyPortAvailable(
|
|
737
|
+
httpPort,
|
|
738
|
+
projectDir = process.cwd(),
|
|
739
|
+
{ classify = classifyPortHolder, stop = stopServer, isInUse = isPortInUse } = {},
|
|
740
|
+
) {
|
|
741
|
+
if (!isUsablePort(httpPort) || !(await isInUse(httpPort))) {
|
|
742
|
+
return { kind: "free" };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const state = readServerState(projectDir);
|
|
746
|
+
const statePort = state && isUsablePort(state.controlPort) ? state.controlPort : null;
|
|
747
|
+
if (statePort !== null) {
|
|
748
|
+
const holder = await classify(statePort, projectDir);
|
|
749
|
+
if (holder.kind === "same-project") {
|
|
750
|
+
console.error(
|
|
751
|
+
`Port ${httpPort} is held by this project's own server on control ` +
|
|
752
|
+
`port ${statePort}; stopping it before starting a fresh one...`,
|
|
753
|
+
);
|
|
754
|
+
await stop(statePort, projectDir);
|
|
755
|
+
if (await waitForPortToClose(httpPort, 5000, 200)) {
|
|
756
|
+
return { kind: "cleared" };
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return { kind: "conflict", pids: findListeningPids(httpPort) };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Build the "the launcher is not going to print a URL for a server that never
|
|
766
|
+
* came up" message, including the tail of the server output log.
|
|
767
|
+
*
|
|
768
|
+
* `tail` is the already-read log tail (see `readServerOutputLogTail`) or `null`
|
|
769
|
+
* when there was nothing readable — passed in rather than read here so the
|
|
770
|
+
* wording is testable without a filesystem, matching the other `format*Error`
|
|
771
|
+
* builders. The diagnostic was never missing from that log; it was written to a
|
|
772
|
+
* file nobody was told to read, which is why the tail is inlined into the
|
|
773
|
+
* message rather than merely pointed at.
|
|
774
|
+
*/
|
|
775
|
+
function formatLaunchFailureError(port, projectDir, tail = null) {
|
|
776
|
+
const logPath = path.join(projectDir, ".codeyam", "logs", "editor-server.out.log");
|
|
777
|
+
const head =
|
|
778
|
+
`Editor server is not listening on port ${port} — not printing a URL ` +
|
|
779
|
+
`that would refuse the connection.`;
|
|
780
|
+
if (tail === null) {
|
|
781
|
+
return (
|
|
782
|
+
`${head}\n\nNo readable ${logPath}; also check ` +
|
|
783
|
+
`${path.join(projectDir, ".codeyam", "logs", "editor-server.log")}.`
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
return `${head}\n\n--- tail of ${logPath} ---\n${tail}\n--- end of log tail ---`;
|
|
787
|
+
}
|
|
788
|
+
|
|
629
789
|
/**
|
|
630
790
|
* Build the "server never bound the port" failure message, shown only after
|
|
631
791
|
* the boot budget AND one automatic retry have both been exhausted. Pure
|
|
@@ -833,6 +993,47 @@ function openServerOutputLog(cwd) {
|
|
|
833
993
|
}
|
|
834
994
|
}
|
|
835
995
|
|
|
996
|
+
/**
|
|
997
|
+
* Last `lines` non-empty-trailing lines of the launcher-managed server output
|
|
998
|
+
* log for `projectDir`, or `null` when there is nothing readable there.
|
|
999
|
+
*
|
|
1000
|
+
* The diagnostic text for a failed launch was never missing — the exact
|
|
1001
|
+
* `codeyam-editor cannot bind 127.0.0.1:<port>` line sat in this file the whole
|
|
1002
|
+
* time. It was written to a file nobody was told to read, so the launcher
|
|
1003
|
+
* surfaces the tail itself rather than printing a URL and exiting 0.
|
|
1004
|
+
*
|
|
1005
|
+
* Reads at most the trailing 64 KiB so a long-lived append-only log never costs
|
|
1006
|
+
* a full file read. Best-effort by design: a missing or unreadable log degrades
|
|
1007
|
+
* to `null`, never to a throw on the failure path.
|
|
1008
|
+
*/
|
|
1009
|
+
function readServerOutputLogTail(projectDir = process.cwd(), lines = 40) {
|
|
1010
|
+
const MAX_TAIL_BYTES = 64 * 1024;
|
|
1011
|
+
let fd = null;
|
|
1012
|
+
try {
|
|
1013
|
+
const logPath = path.join(projectDir, ".codeyam", "logs", "editor-server.out.log");
|
|
1014
|
+
fd = fs.openSync(logPath, "r");
|
|
1015
|
+
const size = fs.fstatSync(fd).size;
|
|
1016
|
+
const length = Math.min(size, MAX_TAIL_BYTES);
|
|
1017
|
+
if (length === 0) return null;
|
|
1018
|
+
const buf = Buffer.alloc(length);
|
|
1019
|
+
fs.readSync(fd, buf, 0, length, size - length);
|
|
1020
|
+
const all = buf.toString("utf8").split("\n");
|
|
1021
|
+
while (all.length > 0 && all[all.length - 1].trim() === "") all.pop();
|
|
1022
|
+
if (all.length === 0) return null;
|
|
1023
|
+
return all.slice(-lines).join("\n");
|
|
1024
|
+
} catch {
|
|
1025
|
+
return null;
|
|
1026
|
+
} finally {
|
|
1027
|
+
if (fd !== null) {
|
|
1028
|
+
try {
|
|
1029
|
+
fs.closeSync(fd);
|
|
1030
|
+
} catch {
|
|
1031
|
+
/* best-effort close on a diagnostic-only read */
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
836
1037
|
/**
|
|
837
1038
|
* Format one launcher-teardown-signal record for
|
|
838
1039
|
* `.codeyam/logs/launcher-signals.log`. Pure: every input is passed in, so the
|
|
@@ -965,23 +1166,23 @@ function findListeningPids(port, runLsof = defaultRunLsof, sleep = sleepSync) {
|
|
|
965
1166
|
}
|
|
966
1167
|
|
|
967
1168
|
/** Stop the Rust backend server if it is running. */
|
|
968
|
-
async function stopServer(port = 14199) {
|
|
1169
|
+
async function stopServer(port = 14199, projectDir = process.cwd()) {
|
|
969
1170
|
if (!(await isPortInUse(port))) {
|
|
970
|
-
clearServerState();
|
|
1171
|
+
clearServerState(projectDir);
|
|
971
1172
|
return false;
|
|
972
1173
|
}
|
|
973
1174
|
|
|
974
1175
|
if (await requestServerShutdown(port)) {
|
|
975
1176
|
if (await waitForPortToClose(port, 10000, 200)) {
|
|
976
|
-
clearServerState();
|
|
1177
|
+
clearServerState(projectDir);
|
|
977
1178
|
return true;
|
|
978
1179
|
}
|
|
979
1180
|
}
|
|
980
1181
|
|
|
981
|
-
const state = readServerState();
|
|
1182
|
+
const state = readServerState(projectDir);
|
|
982
1183
|
if (state && Number.isInteger(state.pid) && tryKillPid(state.pid)) {
|
|
983
1184
|
if (await waitForPortToClose(port, 5000, 200)) {
|
|
984
|
-
clearServerState();
|
|
1185
|
+
clearServerState(projectDir);
|
|
985
1186
|
return true;
|
|
986
1187
|
}
|
|
987
1188
|
}
|
|
@@ -991,20 +1192,37 @@ async function stopServer(port = 14199) {
|
|
|
991
1192
|
}
|
|
992
1193
|
|
|
993
1194
|
if (await waitForPortToClose(port, 5000, 200)) {
|
|
994
|
-
clearServerState();
|
|
1195
|
+
clearServerState(projectDir);
|
|
995
1196
|
return true;
|
|
996
1197
|
}
|
|
997
1198
|
|
|
998
1199
|
throw new Error(`Server on port ${port} did not stop cleanly`);
|
|
999
1200
|
}
|
|
1000
1201
|
|
|
1001
|
-
/**
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1202
|
+
/**
|
|
1203
|
+
* Stop any existing server for this project, then start a fresh one on `port`.
|
|
1204
|
+
*
|
|
1205
|
+
* `port` is the port to BIND (owned by the editor repo's merged config); the
|
|
1206
|
+
* port to TEAR DOWN is resolved separately via `resolveRunningControlPort`,
|
|
1207
|
+
* because a server started outside the npm wrapper records a different, live
|
|
1208
|
+
* control port in the project's `server-state.json`. Gating teardown on `port`
|
|
1209
|
+
* alone is what let a stale server survive a restart and kill the relaunch with
|
|
1210
|
+
* EADDRINUSE on the proxy port. Both are torn down (deduped) so the restart's
|
|
1211
|
+
* historical guarantee — nothing left on the port we are about to bind — holds
|
|
1212
|
+
* whichever port the live server turned out to be on.
|
|
1213
|
+
*/
|
|
1214
|
+
async function restartServer(port = 14199, projectDir = process.cwd()) {
|
|
1215
|
+
const runningPort = resolveRunningControlPort(projectDir, port);
|
|
1216
|
+
const seen = new Set();
|
|
1217
|
+
for (const candidate of [runningPort, port]) {
|
|
1218
|
+
if (!isUsablePort(candidate) || seen.has(candidate)) continue;
|
|
1219
|
+
seen.add(candidate);
|
|
1220
|
+
if (await isPortInUse(candidate)) {
|
|
1221
|
+
console.error(`Stopping existing server on port ${candidate}...`);
|
|
1222
|
+
await stopServer(candidate, projectDir);
|
|
1223
|
+
}
|
|
1006
1224
|
}
|
|
1007
|
-
return ensureServer(port);
|
|
1225
|
+
return ensureServer(port, projectDir);
|
|
1008
1226
|
}
|
|
1009
1227
|
|
|
1010
1228
|
/**
|
|
@@ -1016,8 +1234,8 @@ async function restartServer(port = 14199) {
|
|
|
1016
1234
|
* mismatch — silently choosing a different port would just relocate the
|
|
1017
1235
|
* surprise.
|
|
1018
1236
|
*/
|
|
1019
|
-
async function ensureServer(port = 14199) {
|
|
1020
|
-
const holder = await classifyPortHolder(port);
|
|
1237
|
+
async function ensureServer(port = 14199, projectDir = process.cwd()) {
|
|
1238
|
+
const holder = await classifyPortHolder(port, projectDir);
|
|
1021
1239
|
switch (holder.kind) {
|
|
1022
1240
|
case "foreign":
|
|
1023
1241
|
console.error(formatForeignListenerError(port));
|
|
@@ -1056,6 +1274,21 @@ async function ensureServer(port = 14199) {
|
|
|
1056
1274
|
break;
|
|
1057
1275
|
}
|
|
1058
1276
|
|
|
1277
|
+
// The control port is free (or was just taken over), but that is not the port
|
|
1278
|
+
// the spawn is most likely to die on. `start.rs` binds `proxy.httpPort`
|
|
1279
|
+
// unconditionally, so a holder there is a guaranteed EADDRINUSE — knowable
|
|
1280
|
+
// with one probe here, and previously discovered only by reading a log after
|
|
1281
|
+
// a silent failure.
|
|
1282
|
+
const proxyHttpPort = resolveProxyHttpPort(
|
|
1283
|
+
readMergedEditorConfig(projectDir),
|
|
1284
|
+
process.env.CODEYAM_PROXY_HTTP_PORT,
|
|
1285
|
+
);
|
|
1286
|
+
const proxyPort = await ensureProxyPortAvailable(proxyHttpPort, projectDir);
|
|
1287
|
+
if (proxyPort.kind === "conflict") {
|
|
1288
|
+
console.error(formatProxyPortConflictError(proxyHttpPort, proxyPort.pids));
|
|
1289
|
+
process.exit(1);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1059
1292
|
const binary = ensureBinary();
|
|
1060
1293
|
const startTimeoutMs = resolveStartTimeoutMs();
|
|
1061
1294
|
|
|
@@ -1075,12 +1308,12 @@ async function ensureServer(port = 14199) {
|
|
|
1075
1308
|
// editor-server.log). `unref()` keeps the detached child from pinning the
|
|
1076
1309
|
// launcher's event loop alive on its own.
|
|
1077
1310
|
const spawnAndAwait = async (timeoutMs) => {
|
|
1078
|
-
const logFd = openServerOutputLog(
|
|
1311
|
+
const logFd = openServerOutputLog(projectDir);
|
|
1079
1312
|
const child = spawn(
|
|
1080
1313
|
binary,
|
|
1081
1314
|
["start", "--no-open", "--port", String(port)],
|
|
1082
1315
|
buildServerSpawnOptions({
|
|
1083
|
-
cwd:
|
|
1316
|
+
cwd: projectDir,
|
|
1084
1317
|
env: serverSpawnEnv(process.env, { devCheckout: isDevCheckout() }),
|
|
1085
1318
|
logFd,
|
|
1086
1319
|
}),
|
|
@@ -1215,7 +1448,7 @@ async function awaitServerReadyOrChildExit(child, port, waitForPortFn = waitForP
|
|
|
1215
1448
|
|
|
1216
1449
|
module.exports = {
|
|
1217
1450
|
rootDir,
|
|
1218
|
-
|
|
1451
|
+
serverStatePathFor,
|
|
1219
1452
|
binaryName,
|
|
1220
1453
|
brokerBinaryName,
|
|
1221
1454
|
platformPackageInfo,
|
|
@@ -1241,6 +1474,9 @@ module.exports = {
|
|
|
1241
1474
|
classifyPortHolder,
|
|
1242
1475
|
formatForeignListenerError,
|
|
1243
1476
|
formatCrossProjectError,
|
|
1477
|
+
formatProxyPortConflictError,
|
|
1478
|
+
formatLaunchFailureError,
|
|
1479
|
+
ensureProxyPortAvailable,
|
|
1244
1480
|
formatServerStartTimeoutError,
|
|
1245
1481
|
resolveStartTimeoutMs,
|
|
1246
1482
|
tryKillPid,
|
|
@@ -1264,4 +1500,8 @@ module.exports = {
|
|
|
1264
1500
|
readMergedEditorConfig,
|
|
1265
1501
|
resolveLauncherPort,
|
|
1266
1502
|
resolveWrappedAppPort,
|
|
1503
|
+
resolveRunningControlPort,
|
|
1504
|
+
resolveProxyHttpPort,
|
|
1505
|
+
isUsablePort,
|
|
1506
|
+
readServerOutputLogTail,
|
|
1267
1507
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codeyam-editor/codeyam-editor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Language-agnostic managed execution sandbox for scenario-driven development",
|
|
5
5
|
"bin": {
|
|
6
6
|
"codeyam-editor": "./npm/cli.js"
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"playwright": "^1.58.2"
|
|
10
10
|
},
|
|
11
11
|
"optionalDependencies": {
|
|
12
|
-
"@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.
|
|
13
|
-
"@codeyam-editor/codeyam-editor-darwin-x64": "0.1.
|
|
14
|
-
"@codeyam-editor/codeyam-editor-linux-x64": "0.1.
|
|
15
|
-
"@codeyam-editor/codeyam-editor-win32-x64": "0.1.
|
|
12
|
+
"@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.9",
|
|
13
|
+
"@codeyam-editor/codeyam-editor-darwin-x64": "0.1.9",
|
|
14
|
+
"@codeyam-editor/codeyam-editor-linux-x64": "0.1.9",
|
|
15
|
+
"@codeyam-editor/codeyam-editor-win32-x64": "0.1.9"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
18
18
|
"codeyam",
|