forgeos 0.1.0-alpha.42 → 0.1.0-alpha.44
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/AGENTS.md +1 -1
- package/CHANGELOG.md +29 -0
- package/docs/changelog.md +21 -0
- package/package.json +2 -1
- package/scripts/field-test-forgeos.mjs +1012 -0
- package/src/forge/_generated/releaseManifest.json +1 -1
- package/src/forge/_generated/releaseManifest.ts +3 -3
- package/src/forge/cli/field-test.ts +13 -6
- package/src/forge/ui/index.ts +5 -2
- package/src/forge/version.ts +1 -1
package/AGENTS.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @forge-generated generator=0.1.0-alpha.
|
|
1
|
+
// @forge-generated generator=0.1.0-alpha.44 input=3c44e5f9f6fd4afe42accec6d73bca4f36f188ed9de5fb11f4a890d3020fe2bc content=721818a6f9a664aa898092d4aa5cd68cbbd8ad6e150c7d3ad3ef785a2e3fcf53
|
|
2
2
|
# AGENTS.md
|
|
3
3
|
|
|
4
4
|
<!-- forge-generated:start -->
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# forgeos
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.44
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Remove a false positive from UI ergonomics checks for generated apps that
|
|
8
|
+
auto-seed through a helper inside `useEffect`.
|
|
9
|
+
|
|
10
|
+
- `forge inspect ui --ergonomics` now recognizes `runSeed...` helper calls
|
|
11
|
+
inside bounded `useEffect` blocks as automatic first-run seed recovery.
|
|
12
|
+
- The `vendor-access` template regression now asserts that connected
|
|
13
|
+
auto-seed scenarios do not emit `FORGE_UI_AUTO_SEED_RECOVERY_MISSING`,
|
|
14
|
+
keeping `forge field-test run --realistic` aligned with deploy-readiness
|
|
15
|
+
expectations.
|
|
16
|
+
|
|
17
|
+
## 0.1.0-alpha.43
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- Fix `forge field-test run` in apps that use the published `forgeos` package.
|
|
22
|
+
|
|
23
|
+
- Include `scripts/field-test-forgeos.mjs` in the npm package so generated
|
|
24
|
+
apps can run the field-test harness without having a local ForgeOS checkout.
|
|
25
|
+
- Resolve the harness from either the app workspace or the installed package,
|
|
26
|
+
which keeps framework checkouts and installed-package app workflows working
|
|
27
|
+
with the same command.
|
|
28
|
+
- Improve the missing-harness diagnostic by reporting the searched paths and
|
|
29
|
+
suggesting an upgrade when the installed package does not include the
|
|
30
|
+
packaged harness.
|
|
31
|
+
|
|
3
32
|
## 0.1.0-alpha.42
|
|
4
33
|
|
|
5
34
|
### Patch Changes
|
package/docs/changelog.md
CHANGED
|
@@ -6,6 +6,27 @@ The canonical source file in the repository is `CHANGELOG.md`.
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.1.0-alpha.44
|
|
10
|
+
|
|
11
|
+
- `forge inspect ui --ergonomics` now treats helper-based seed recovery inside
|
|
12
|
+
`useEffect` as valid automatic first-run recovery, fixing a false
|
|
13
|
+
`FORGE_UI_AUTO_SEED_RECOVERY_MISSING` warning in the `vendor-access`
|
|
14
|
+
template.
|
|
15
|
+
- Added template regression coverage so `vendor-access` keeps its seed
|
|
16
|
+
controls, auto-seed UI scenario, and zero-warning ergonomics evidence in sync
|
|
17
|
+
with `forge field-test run --realistic`.
|
|
18
|
+
|
|
19
|
+
## 0.1.0-alpha.43
|
|
20
|
+
|
|
21
|
+
- Included `scripts/field-test-forgeos.mjs` in the published npm package so
|
|
22
|
+
`forge field-test run` works from generated apps installed from
|
|
23
|
+
`forgeos@alpha`, not only from the ForgeOS framework checkout.
|
|
24
|
+
- `forge field-test run` now resolves the harness from the current workspace
|
|
25
|
+
first and then from the installed ForgeOS package, with a clearer diagnostic
|
|
26
|
+
listing searched paths when neither location exists.
|
|
27
|
+
- Added regression coverage for running the field-test command from an app
|
|
28
|
+
workspace that does not contain a local `scripts/field-test-forgeos.mjs`.
|
|
29
|
+
|
|
9
30
|
## 0.1.0-alpha.42
|
|
10
31
|
|
|
11
32
|
- Added the golden-path CLI alias `forge field-test run --realistic`, which
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgeos",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.44",
|
|
4
4
|
"description": "Agent-native application framework and compiler for building Forge apps without a mandatory dashboard.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"!adapters/**/*.class",
|
|
11
11
|
"!adapters/**/*.jar",
|
|
12
12
|
"bin/",
|
|
13
|
+
"scripts/field-test-forgeos.mjs",
|
|
13
14
|
"docs/cair-protocol.md",
|
|
14
15
|
"docs/forge-protocol.md",
|
|
15
16
|
"examples/go-billing/",
|
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { access, mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const args = {
|
|
12
|
+
authProbes: false,
|
|
13
|
+
dryRun: false,
|
|
14
|
+
install: true,
|
|
15
|
+
json: false,
|
|
16
|
+
keep: false,
|
|
17
|
+
runtimeProbes: false,
|
|
18
|
+
uiProbes: false,
|
|
19
|
+
timeoutMs: 180000,
|
|
20
|
+
templates: ["minimal-web"],
|
|
21
|
+
packageManagers: ["npm"],
|
|
22
|
+
writeReport: undefined,
|
|
23
|
+
forgeSpec: `file:${repoRoot}`,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
27
|
+
const arg = argv[index];
|
|
28
|
+
if (arg === "--auth-probes") args.authProbes = true;
|
|
29
|
+
else if (arg === "--dry-run") args.dryRun = true;
|
|
30
|
+
else if (arg === "--install") args.install = true;
|
|
31
|
+
else if (arg === "--no-install") args.install = false;
|
|
32
|
+
else if (arg === "--json") args.json = true;
|
|
33
|
+
else if (arg === "--keep") args.keep = true;
|
|
34
|
+
else if (arg === "--runtime-probes") args.runtimeProbes = true;
|
|
35
|
+
else if (arg === "--ui-probes") args.uiProbes = true;
|
|
36
|
+
else if (arg === "--timeout-ms") args.timeoutMs = Number(argv[++index]);
|
|
37
|
+
else if (arg === "--templates") args.templates = splitList(argv[++index]);
|
|
38
|
+
else if (arg === "--package-managers") args.packageManagers = splitList(argv[++index]);
|
|
39
|
+
else if (arg === "--forge-spec") args.forgeSpec = argv[++index];
|
|
40
|
+
else if (arg === "--write-report") args.writeReport = argv[++index];
|
|
41
|
+
else throw new Error(`Unknown argument: ${arg}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
args.forgeSpec = normalizeForgeSpec(args.forgeSpec);
|
|
45
|
+
|
|
46
|
+
if (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
|
|
47
|
+
throw new Error("--timeout-ms must be a positive number");
|
|
48
|
+
}
|
|
49
|
+
return args;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function splitList(value) {
|
|
53
|
+
return String(value ?? "")
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((item) => item.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeForgeSpec(spec) {
|
|
60
|
+
if (!spec.startsWith("file:")) return spec;
|
|
61
|
+
const fileTarget = spec.slice("file:".length);
|
|
62
|
+
if (fileTarget === "" || fileTarget.startsWith("$") || isAbsolute(fileTarget)) {
|
|
63
|
+
return spec;
|
|
64
|
+
}
|
|
65
|
+
return `file:${resolve(process.cwd(), fileTarget)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function commandName(command) {
|
|
69
|
+
return process.platform === "win32" ? `${command}.cmd` : command;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function commandLine(command, args) {
|
|
73
|
+
return [command, ...args].join(" ");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function compactText(text, maxLength = 4000) {
|
|
77
|
+
const value = String(text ?? "");
|
|
78
|
+
if (value.length <= maxLength) return value;
|
|
79
|
+
return `${value.slice(0, Math.floor(maxLength / 2))}\n...[truncated ${value.length - maxLength} chars]...\n${value.slice(-Math.floor(maxLength / 2))}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function compactStep(step) {
|
|
83
|
+
return {
|
|
84
|
+
...step,
|
|
85
|
+
stderr: compactText(step.stderr),
|
|
86
|
+
stdout: compactText(step.stdout),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseJsonObjectFromOutput(output) {
|
|
91
|
+
const text = String(output ?? "").trim();
|
|
92
|
+
if (!text) return null;
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(text);
|
|
95
|
+
} catch {
|
|
96
|
+
const start = text.indexOf("{");
|
|
97
|
+
const end = text.lastIndexOf("}");
|
|
98
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseJsonObjectsFromOutput(output) {
|
|
108
|
+
return String(output ?? "")
|
|
109
|
+
.split(/\r?\n/)
|
|
110
|
+
.map((line) => line.trim())
|
|
111
|
+
.filter((line) => line.startsWith("{") && line.endsWith("}"))
|
|
112
|
+
.map((line) => {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(line);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
.filter(Boolean);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function waitForDevStartup(readOutput, timeoutMs) {
|
|
123
|
+
const deadline = Date.now() + timeoutMs;
|
|
124
|
+
let lastOutput = "";
|
|
125
|
+
while (Date.now() < deadline) {
|
|
126
|
+
const output = readOutput();
|
|
127
|
+
lastOutput = output;
|
|
128
|
+
const summaries = parseJsonObjectsFromOutput(output);
|
|
129
|
+
const startup = summaries.find((item) => item?.ok === true && item?.api?.url);
|
|
130
|
+
if (startup) {
|
|
131
|
+
return startup;
|
|
132
|
+
}
|
|
133
|
+
await sleep(250);
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`Forge dev did not emit startup JSON with api.url before timeout. Output: ${compactText(lastOutput, 1200)}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function summarizeUiErgonomics(step) {
|
|
139
|
+
const payload = parseJsonObjectFromOutput(step.stdout);
|
|
140
|
+
const data = payload && typeof payload === "object" && payload.data && typeof payload.data === "object"
|
|
141
|
+
? payload.data
|
|
142
|
+
: {};
|
|
143
|
+
const diagnostics = Array.isArray(data.diagnostics)
|
|
144
|
+
? data.diagnostics
|
|
145
|
+
: Array.isArray(payload?.diagnostics)
|
|
146
|
+
? payload.diagnostics
|
|
147
|
+
: [];
|
|
148
|
+
const scenarios = Array.isArray(data.scenarios)
|
|
149
|
+
? data.scenarios
|
|
150
|
+
: Array.isArray(payload?.scenarios)
|
|
151
|
+
? payload.scenarios
|
|
152
|
+
: [];
|
|
153
|
+
const manifestSelectors = Array.isArray(data.manifest?.selectors)
|
|
154
|
+
? data.manifest.selectors
|
|
155
|
+
: Array.isArray(payload?.manifest?.selectors)
|
|
156
|
+
? payload.manifest.selectors
|
|
157
|
+
: [];
|
|
158
|
+
const warnings = Array.isArray(payload?.warnings)
|
|
159
|
+
? payload.warnings
|
|
160
|
+
: diagnostics.filter((item) => item?.severity === "warning");
|
|
161
|
+
const errors = Array.isArray(payload?.errors)
|
|
162
|
+
? payload.errors
|
|
163
|
+
: diagnostics.filter((item) => item?.severity === "error");
|
|
164
|
+
return {
|
|
165
|
+
command: step.command,
|
|
166
|
+
ok: step.ok && errors.length === 0,
|
|
167
|
+
exitCode: step.exitCode,
|
|
168
|
+
warnings: warnings.length,
|
|
169
|
+
errors: errors.length,
|
|
170
|
+
diagnosticCodes: diagnostics
|
|
171
|
+
.map((item) => item?.code)
|
|
172
|
+
.filter(Boolean)
|
|
173
|
+
.slice(0, 20),
|
|
174
|
+
scenarioNames: scenarios
|
|
175
|
+
.map((item) => item?.name)
|
|
176
|
+
.filter(Boolean)
|
|
177
|
+
.slice(0, 50),
|
|
178
|
+
selectors: manifestSelectors.filter(Boolean).slice(0, 50),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function packageScriptArgs(pm, script, extraArgs = []) {
|
|
183
|
+
if (pm === "npm") return ["run", script, ...(extraArgs.length > 0 ? ["--", ...extraArgs] : [])];
|
|
184
|
+
return ["run", script, ...extraArgs];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function commandExists(command) {
|
|
188
|
+
const probe = process.platform === "win32" ? "where" : "sh";
|
|
189
|
+
const args = process.platform === "win32" ? [command] : ["-c", `command -v ${shellQuote(command)}`];
|
|
190
|
+
const result = await runCommand(probe, args, { timeoutMs: 10000, allowFailure: true });
|
|
191
|
+
return result.exitCode === 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function shellQuote(value) {
|
|
195
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function runCommand(command, args, options = {}) {
|
|
199
|
+
const timeoutMs = options.timeoutMs ?? 180000;
|
|
200
|
+
const startedAt = Date.now();
|
|
201
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
202
|
+
const spawnTarget = windowsBatchTarget(command, args);
|
|
203
|
+
let child;
|
|
204
|
+
try {
|
|
205
|
+
child = spawn(spawnTarget.command, spawnTarget.args, {
|
|
206
|
+
cwd: options.cwd ?? repoRoot,
|
|
207
|
+
env: { ...process.env, ...(options.env ?? {}) },
|
|
208
|
+
shell: false,
|
|
209
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
210
|
+
windowsHide: true,
|
|
211
|
+
});
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const commandText = commandLine(command, args);
|
|
214
|
+
if (options.allowFailure) {
|
|
215
|
+
resolveRun({
|
|
216
|
+
command: commandText,
|
|
217
|
+
durationMs: Date.now() - startedAt,
|
|
218
|
+
error: error instanceof Error ? error.message : String(error),
|
|
219
|
+
exitCode: 127,
|
|
220
|
+
ok: false,
|
|
221
|
+
stderr: "",
|
|
222
|
+
stdout: "",
|
|
223
|
+
timedOut: false,
|
|
224
|
+
});
|
|
225
|
+
} else {
|
|
226
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
227
|
+
rejectRun(new Error(`${commandText}: ${message}`));
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
let stdout = "";
|
|
232
|
+
let stderr = "";
|
|
233
|
+
let timedOut = false;
|
|
234
|
+
const timer = setTimeout(() => {
|
|
235
|
+
timedOut = true;
|
|
236
|
+
child.kill("SIGTERM");
|
|
237
|
+
}, timeoutMs);
|
|
238
|
+
|
|
239
|
+
child.stdout.on("data", (chunk) => {
|
|
240
|
+
stdout += chunk.toString();
|
|
241
|
+
});
|
|
242
|
+
child.stderr.on("data", (chunk) => {
|
|
243
|
+
stderr += chunk.toString();
|
|
244
|
+
});
|
|
245
|
+
child.on("error", (error) => {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
const commandText = commandLine(command, args);
|
|
248
|
+
if (options.allowFailure) {
|
|
249
|
+
resolveRun({
|
|
250
|
+
command: commandText,
|
|
251
|
+
durationMs: Date.now() - startedAt,
|
|
252
|
+
error: error.message,
|
|
253
|
+
exitCode: 127,
|
|
254
|
+
ok: false,
|
|
255
|
+
stderr,
|
|
256
|
+
stdout,
|
|
257
|
+
timedOut,
|
|
258
|
+
});
|
|
259
|
+
} else {
|
|
260
|
+
error.message = `${commandText}: ${error.message}`;
|
|
261
|
+
rejectRun(error);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
child.on("close", (exitCode) => {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
const result = {
|
|
267
|
+
command: commandLine(command, args),
|
|
268
|
+
durationMs: Date.now() - startedAt,
|
|
269
|
+
exitCode,
|
|
270
|
+
ok: exitCode === 0 && !timedOut,
|
|
271
|
+
stderr,
|
|
272
|
+
stdout,
|
|
273
|
+
timedOut,
|
|
274
|
+
};
|
|
275
|
+
if (!result.ok && !options.allowFailure) {
|
|
276
|
+
const error = new Error(`Command failed: ${result.command}`);
|
|
277
|
+
error.result = result;
|
|
278
|
+
rejectRun(error);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
resolveRun(result);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function sleep(ms) {
|
|
287
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const VENDOR_ACCESS_TENANTS = {
|
|
291
|
+
acme: "11111111-1111-4111-8111-111111111111",
|
|
292
|
+
globex: "22222222-2222-4222-8222-222222222222",
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
function authHeaders(overrides = {}) {
|
|
296
|
+
const permissions = overrides.permissions ?? [];
|
|
297
|
+
return {
|
|
298
|
+
"content-type": "application/json",
|
|
299
|
+
"x-forge-role": overrides.role ?? "owner",
|
|
300
|
+
"x-forge-tenant-id": overrides.tenantId ?? "00000000-0000-0000-0000-000000000001",
|
|
301
|
+
"x-forge-user-id": overrides.userId ?? "field-test-user",
|
|
302
|
+
...(permissions.length > 0 ? { "x-forge-permissions": JSON.stringify(permissions) } : {}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function fetchJson(url, options = {}) {
|
|
307
|
+
const response = await fetch(url, options);
|
|
308
|
+
const text = await response.text();
|
|
309
|
+
let body;
|
|
310
|
+
try {
|
|
311
|
+
body = text ? JSON.parse(text) : null;
|
|
312
|
+
} catch {
|
|
313
|
+
body = text;
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
body,
|
|
317
|
+
ok: response.ok,
|
|
318
|
+
status: response.status,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function fetchProbe(url, options = {}) {
|
|
323
|
+
const response = await fetch(url, options);
|
|
324
|
+
const text = options.method === "HEAD" ? "" : await response.text();
|
|
325
|
+
return {
|
|
326
|
+
body: text,
|
|
327
|
+
ok: response.ok,
|
|
328
|
+
status: response.status,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function httpStep({ command, result, startedAt, ok }) {
|
|
333
|
+
return {
|
|
334
|
+
command,
|
|
335
|
+
durationMs: Date.now() - startedAt,
|
|
336
|
+
exitCode: ok ? 0 : 1,
|
|
337
|
+
ok,
|
|
338
|
+
status: result.status,
|
|
339
|
+
traceId: result.body?.traceId,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function resultRows(result, key) {
|
|
344
|
+
const value = result.body?.result?.[key];
|
|
345
|
+
return Array.isArray(value) ? value : [];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function waitForHealth(url, timeoutMs) {
|
|
349
|
+
const startedAt = Date.now();
|
|
350
|
+
let lastError = "not started";
|
|
351
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
352
|
+
try {
|
|
353
|
+
const result = await fetchJson(`${url}/health`);
|
|
354
|
+
if (result.ok && result.body?.ok === true) {
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
lastError = `HTTP ${result.status}`;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
360
|
+
}
|
|
361
|
+
await sleep(500);
|
|
362
|
+
}
|
|
363
|
+
throw new Error(`Dev server did not become healthy at ${url}: ${lastError}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function waitForWeb(url, timeoutMs) {
|
|
367
|
+
const startedAt = Date.now();
|
|
368
|
+
let lastError = "not started";
|
|
369
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
370
|
+
try {
|
|
371
|
+
const result = await fetchProbe(url);
|
|
372
|
+
const failureCopy = visibleWebFailureCopy(result.body);
|
|
373
|
+
if (result.ok && /<html|<div\s+id=["']root["']|__nuxt|_next/i.test(result.body)) {
|
|
374
|
+
if (failureCopy) {
|
|
375
|
+
lastError = failureCopy;
|
|
376
|
+
await sleep(500);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
lastError = `HTTP ${result.status}`;
|
|
382
|
+
} catch (error) {
|
|
383
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
384
|
+
}
|
|
385
|
+
await sleep(500);
|
|
386
|
+
}
|
|
387
|
+
throw new Error(`Web server did not become reachable at ${url}: ${lastError}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function visibleWebFailureCopy(body) {
|
|
391
|
+
const text = String(body ?? "");
|
|
392
|
+
if (/Failed to fetch/i.test(text)) {
|
|
393
|
+
return "web UI rendered 'Failed to fetch'";
|
|
394
|
+
}
|
|
395
|
+
if (/No organization seeded|No organisation seeded/i.test(text)) {
|
|
396
|
+
return "web UI rendered an unseeded-organization state";
|
|
397
|
+
}
|
|
398
|
+
if (/FORGE_DEV_SERVER_ERROR|FORGE_POLICY_DENIED/i.test(text)) {
|
|
399
|
+
return "web UI rendered a raw Forge runtime error";
|
|
400
|
+
}
|
|
401
|
+
return "";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function seedCommandsFromEntries(entriesBody) {
|
|
405
|
+
const entries = Array.isArray(entriesBody?.entries) ? entriesBody.entries : [];
|
|
406
|
+
return entries
|
|
407
|
+
.filter((entry) => entry?.kind === "command" && /(^|[._-])seed|seed[A-Z_.-]?/i.test(String(entry.name ?? "")))
|
|
408
|
+
.map((entry) => String(entry.name))
|
|
409
|
+
.sort();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function stopProcessTree(child) {
|
|
413
|
+
if (!child.pid) return;
|
|
414
|
+
if (process.platform === "win32") {
|
|
415
|
+
await new Promise((resolveStop) => {
|
|
416
|
+
const killer = spawn("taskkill.exe", ["/pid", String(child.pid), "/t", "/f"], {
|
|
417
|
+
stdio: "ignore",
|
|
418
|
+
windowsHide: true,
|
|
419
|
+
});
|
|
420
|
+
const timer = setTimeout(resolveStop, 5000);
|
|
421
|
+
killer.once("close", () => {
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
resolveStop();
|
|
424
|
+
});
|
|
425
|
+
killer.once("error", () => {
|
|
426
|
+
clearTimeout(timer);
|
|
427
|
+
resolveStop();
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
child.kill("SIGTERM");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function expectedWebText(template) {
|
|
436
|
+
if (template === "vendor-access") return /Vendor Access/i;
|
|
437
|
+
if (template === "b2b-support-web") return /support|ticket|b2b/i;
|
|
438
|
+
return /Forge|Note|Nuxt|Vite|root/i;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function runRuntimeProbes({ appDir, authProbes, packageManager, template, timeoutMs, uiProbes }) {
|
|
442
|
+
const pm = commandName(packageManager);
|
|
443
|
+
const scriptArgs = packageScriptArgs(packageManager, "forge", [
|
|
444
|
+
"dev",
|
|
445
|
+
...(uiProbes ? ["--web-port", "0"] : ["--api-only"]),
|
|
446
|
+
"--port",
|
|
447
|
+
"0",
|
|
448
|
+
"--json",
|
|
449
|
+
"--skip-startup-console",
|
|
450
|
+
]);
|
|
451
|
+
const startedAt = Date.now();
|
|
452
|
+
const spawnTarget = windowsBatchTarget(pm, scriptArgs);
|
|
453
|
+
const child = spawn(spawnTarget.command, spawnTarget.args, {
|
|
454
|
+
cwd: appDir,
|
|
455
|
+
env: { ...process.env },
|
|
456
|
+
shell: false,
|
|
457
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
458
|
+
windowsHide: true,
|
|
459
|
+
});
|
|
460
|
+
let stdout = "";
|
|
461
|
+
let stderr = "";
|
|
462
|
+
let childError;
|
|
463
|
+
const steps = [];
|
|
464
|
+
|
|
465
|
+
child.stdout.on("data", (chunk) => {
|
|
466
|
+
stdout += chunk.toString();
|
|
467
|
+
});
|
|
468
|
+
child.stderr.on("data", (chunk) => {
|
|
469
|
+
stderr += chunk.toString();
|
|
470
|
+
});
|
|
471
|
+
child.once("error", (error) => {
|
|
472
|
+
childError = error;
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
if (childError) {
|
|
477
|
+
throw new Error(`Could not start forge dev: ${childError.message}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const startup = await waitForDevStartup(() => stdout, Math.min(timeoutMs, 120000));
|
|
481
|
+
const serverUrl = String(startup.api.url);
|
|
482
|
+
const webUrl = uiProbes && startup.web?.url ? String(startup.web.url) : undefined;
|
|
483
|
+
if (uiProbes && !webUrl) {
|
|
484
|
+
throw new Error(`Forge dev startup JSON did not include web.url while --ui-probes was enabled: ${compactText(JSON.stringify(startup), 1200)}`);
|
|
485
|
+
}
|
|
486
|
+
steps.push({
|
|
487
|
+
command: "forge dev startup",
|
|
488
|
+
durationMs: Date.now() - startedAt,
|
|
489
|
+
exitCode: 0,
|
|
490
|
+
ok: true,
|
|
491
|
+
stdout: JSON.stringify({
|
|
492
|
+
api: startup.api,
|
|
493
|
+
web: startup.web ?? null,
|
|
494
|
+
}),
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const health = await waitForHealth(serverUrl, Math.min(timeoutMs, 120000));
|
|
498
|
+
steps.push({
|
|
499
|
+
command: `GET ${serverUrl}/health`,
|
|
500
|
+
durationMs: Date.now() - startedAt,
|
|
501
|
+
exitCode: 0,
|
|
502
|
+
ok: true,
|
|
503
|
+
status: health.status,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
if (uiProbes && webUrl) {
|
|
507
|
+
const webStartedAt = Date.now();
|
|
508
|
+
const web = await waitForWeb(webUrl, Math.min(timeoutMs, 120000));
|
|
509
|
+
const expected = expectedWebText(template);
|
|
510
|
+
steps.push({
|
|
511
|
+
command: `GET ${webUrl}/`,
|
|
512
|
+
durationMs: Date.now() - webStartedAt,
|
|
513
|
+
exitCode: web.ok && expected.test(web.body) ? 0 : 1,
|
|
514
|
+
ok: web.ok && expected.test(web.body),
|
|
515
|
+
status: web.status,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const entries = await fetchJson(`${serverUrl}/entries`);
|
|
520
|
+
steps.push({
|
|
521
|
+
command: `GET ${serverUrl}/entries`,
|
|
522
|
+
durationMs: Date.now() - startedAt,
|
|
523
|
+
exitCode: entries.ok ? 0 : 1,
|
|
524
|
+
ok: entries.ok && entries.body?.ok === true,
|
|
525
|
+
status: entries.status,
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const seedCommands = seedCommandsFromEntries(entries.body);
|
|
529
|
+
if (seedCommands.length > 0) {
|
|
530
|
+
const seedCommand = seedCommands[0];
|
|
531
|
+
for (const [label, args] of [
|
|
532
|
+
["seed-status", ["seed", "status", "--json"]],
|
|
533
|
+
["seed-dev", ["seed", "dev", "--command", seedCommand, "--url", serverUrl, "--json"]],
|
|
534
|
+
["seed-reset", ["seed", "reset", "--command", seedCommand, "--url", serverUrl, "--json"]],
|
|
535
|
+
]) {
|
|
536
|
+
const result = await runCommand(
|
|
537
|
+
pm,
|
|
538
|
+
packageScriptArgs(packageManager, "forge", args),
|
|
539
|
+
{ cwd: appDir, timeoutMs, allowFailure: true },
|
|
540
|
+
);
|
|
541
|
+
steps.push({
|
|
542
|
+
...result,
|
|
543
|
+
command: `${label}: ${result.command}`,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (authProbes) {
|
|
549
|
+
for (const [method, path] of [
|
|
550
|
+
["HEAD", "/auth.md"],
|
|
551
|
+
["GET", "/auth.md"],
|
|
552
|
+
["HEAD", "/.well-known/oauth-protected-resource"],
|
|
553
|
+
["GET", "/.well-known/oauth-protected-resource"],
|
|
554
|
+
]) {
|
|
555
|
+
const probe = await fetchProbe(`${serverUrl}${path}`, { method });
|
|
556
|
+
steps.push({
|
|
557
|
+
command: `${method} ${serverUrl}${path}`,
|
|
558
|
+
durationMs: Date.now() - startedAt,
|
|
559
|
+
exitCode: probe.ok ? 0 : 1,
|
|
560
|
+
ok: probe.ok,
|
|
561
|
+
status: probe.status,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (template === "vendor-access") {
|
|
567
|
+
const ownerPermissions = [
|
|
568
|
+
"demo:seed",
|
|
569
|
+
"vendors:read",
|
|
570
|
+
"vendors:manage",
|
|
571
|
+
"access:request",
|
|
572
|
+
"access:approve",
|
|
573
|
+
"evidence:manage",
|
|
574
|
+
"audit:read",
|
|
575
|
+
];
|
|
576
|
+
const requesterPermissions = ["vendors:read", "access:request", "audit:read"];
|
|
577
|
+
const acmeOwner = authHeaders({
|
|
578
|
+
tenantId: VENDOR_ACCESS_TENANTS.acme,
|
|
579
|
+
userId: "riley@acme.example",
|
|
580
|
+
role: "owner",
|
|
581
|
+
permissions: ownerPermissions,
|
|
582
|
+
});
|
|
583
|
+
const globexOwner = authHeaders({
|
|
584
|
+
tenantId: VENDOR_ACCESS_TENANTS.globex,
|
|
585
|
+
userId: "nina@globex.example",
|
|
586
|
+
role: "security",
|
|
587
|
+
permissions: ownerPermissions,
|
|
588
|
+
});
|
|
589
|
+
const acmeRequester = authHeaders({
|
|
590
|
+
tenantId: VENDOR_ACCESS_TENANTS.acme,
|
|
591
|
+
userId: "maya@acme.example",
|
|
592
|
+
role: "requester",
|
|
593
|
+
permissions: requesterPermissions,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const seedAllTenants = await runCommand(
|
|
597
|
+
pm,
|
|
598
|
+
packageScriptArgs(packageManager, "forge", [
|
|
599
|
+
"seed",
|
|
600
|
+
"dev",
|
|
601
|
+
"--command",
|
|
602
|
+
"seedVendorAccessDemo",
|
|
603
|
+
"--url",
|
|
604
|
+
serverUrl,
|
|
605
|
+
"--all-tenants",
|
|
606
|
+
"--json",
|
|
607
|
+
]),
|
|
608
|
+
{ cwd: appDir, timeoutMs, allowFailure: true },
|
|
609
|
+
);
|
|
610
|
+
steps.push({
|
|
611
|
+
...seedAllTenants,
|
|
612
|
+
command: `vendor-access-seed-all-tenants: ${seedAllTenants.command}`,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const acmeDashboard = await fetchJson(`${serverUrl}/queries/listVendorAccessDashboard`, {
|
|
616
|
+
body: JSON.stringify({ args: {} }),
|
|
617
|
+
headers: acmeOwner,
|
|
618
|
+
method: "POST",
|
|
619
|
+
});
|
|
620
|
+
const acmeVendors = resultRows(acmeDashboard, "vendors");
|
|
621
|
+
const acmeRequests = resultRows(acmeDashboard, "accessRequests");
|
|
622
|
+
const acmeOrganizations = resultRows(acmeDashboard, "organizations");
|
|
623
|
+
steps.push(httpStep({
|
|
624
|
+
command: `vendor-access-query-acme: POST ${serverUrl}/queries/listVendorAccessDashboard`,
|
|
625
|
+
result: acmeDashboard,
|
|
626
|
+
startedAt,
|
|
627
|
+
ok:
|
|
628
|
+
acmeDashboard.ok &&
|
|
629
|
+
acmeOrganizations.length === 1 &&
|
|
630
|
+
acmeOrganizations.some((organization) => organization.id === VENDOR_ACCESS_TENANTS.acme && organization.name === "Acme Corp") &&
|
|
631
|
+
!acmeOrganizations.some((organization) => organization.id === VENDOR_ACCESS_TENANTS.globex || organization.name === "Globex Security") &&
|
|
632
|
+
acmeVendors.some((vendor) => vendor.name === "Atlas Identity") &&
|
|
633
|
+
!acmeVendors.some((vendor) => vendor.name === "Mercury Cloud"),
|
|
634
|
+
}));
|
|
635
|
+
|
|
636
|
+
const globexDashboard = await fetchJson(`${serverUrl}/queries/listVendorAccessDashboard`, {
|
|
637
|
+
body: JSON.stringify({ args: {} }),
|
|
638
|
+
headers: globexOwner,
|
|
639
|
+
method: "POST",
|
|
640
|
+
});
|
|
641
|
+
const globexVendors = resultRows(globexDashboard, "vendors");
|
|
642
|
+
const globexRequests = resultRows(globexDashboard, "accessRequests");
|
|
643
|
+
const globexOrganizations = resultRows(globexDashboard, "organizations");
|
|
644
|
+
steps.push(httpStep({
|
|
645
|
+
command: `vendor-access-query-globex: POST ${serverUrl}/queries/listVendorAccessDashboard`,
|
|
646
|
+
result: globexDashboard,
|
|
647
|
+
startedAt,
|
|
648
|
+
ok:
|
|
649
|
+
globexDashboard.ok &&
|
|
650
|
+
globexOrganizations.length === 1 &&
|
|
651
|
+
globexOrganizations.some((organization) => organization.id === VENDOR_ACCESS_TENANTS.globex && organization.name === "Globex Security") &&
|
|
652
|
+
!globexOrganizations.some((organization) => organization.id === VENDOR_ACCESS_TENANTS.acme || organization.name === "Acme Corp") &&
|
|
653
|
+
globexVendors.some((vendor) => vendor.name === "Mercury Cloud") &&
|
|
654
|
+
!globexVendors.some((vendor) => vendor.name === "Atlas Identity"),
|
|
655
|
+
}));
|
|
656
|
+
|
|
657
|
+
const acmePending = acmeRequests.find((request) => request.status === "Pending");
|
|
658
|
+
const globexPending = globexRequests.find((request) => request.status === "Pending");
|
|
659
|
+
const ownerApprove = await fetchJson(`${serverUrl}/commands/approveAccessRequest`, {
|
|
660
|
+
body: JSON.stringify({
|
|
661
|
+
args: {
|
|
662
|
+
requestId: acmePending?.id ?? "missing-request",
|
|
663
|
+
reviewerEmail: "riley@acme.example",
|
|
664
|
+
decision: "Approved",
|
|
665
|
+
},
|
|
666
|
+
}),
|
|
667
|
+
headers: acmeOwner,
|
|
668
|
+
method: "POST",
|
|
669
|
+
});
|
|
670
|
+
steps.push(httpStep({
|
|
671
|
+
command: `vendor-access-owner-approve: POST ${serverUrl}/commands/approveAccessRequest`,
|
|
672
|
+
result: ownerApprove,
|
|
673
|
+
startedAt,
|
|
674
|
+
ok: ownerApprove.ok && ownerApprove.body?.ok === true,
|
|
675
|
+
}));
|
|
676
|
+
|
|
677
|
+
const requesterDenied = await fetchJson(`${serverUrl}/commands/approveAccessRequest`, {
|
|
678
|
+
body: JSON.stringify({
|
|
679
|
+
args: {
|
|
680
|
+
requestId: acmePending?.id ?? "missing-request",
|
|
681
|
+
reviewerEmail: "maya@acme.example",
|
|
682
|
+
decision: "Rejected",
|
|
683
|
+
},
|
|
684
|
+
}),
|
|
685
|
+
headers: acmeRequester,
|
|
686
|
+
method: "POST",
|
|
687
|
+
});
|
|
688
|
+
steps.push(httpStep({
|
|
689
|
+
command: `vendor-access-requester-approve-denied: POST ${serverUrl}/commands/approveAccessRequest`,
|
|
690
|
+
result: requesterDenied,
|
|
691
|
+
startedAt,
|
|
692
|
+
ok:
|
|
693
|
+
requesterDenied.status === 403 &&
|
|
694
|
+
/FORGE_POLICY_DENIED|access:approve|denied/i.test(JSON.stringify(requesterDenied.body)),
|
|
695
|
+
}));
|
|
696
|
+
|
|
697
|
+
const crossTenantDenied = await fetchJson(`${serverUrl}/commands/approveAccessRequest`, {
|
|
698
|
+
body: JSON.stringify({
|
|
699
|
+
args: {
|
|
700
|
+
requestId: globexPending?.id ?? "missing-request",
|
|
701
|
+
reviewerEmail: "riley@acme.example",
|
|
702
|
+
decision: "Approved",
|
|
703
|
+
},
|
|
704
|
+
}),
|
|
705
|
+
headers: acmeOwner,
|
|
706
|
+
method: "POST",
|
|
707
|
+
});
|
|
708
|
+
steps.push(httpStep({
|
|
709
|
+
command: `vendor-access-cross-tenant-approve-denied: POST ${serverUrl}/commands/approveAccessRequest`,
|
|
710
|
+
result: crossTenantDenied,
|
|
711
|
+
startedAt,
|
|
712
|
+
ok:
|
|
713
|
+
!crossTenantDenied.ok &&
|
|
714
|
+
/not found|current tenant|FORGE_TENANT|tenant/i.test(JSON.stringify(crossTenantDenied.body)),
|
|
715
|
+
}));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (template === "minimal-web" || template === "nuxt-web") {
|
|
719
|
+
const create = await fetchJson(`${serverUrl}/commands/createNote`, {
|
|
720
|
+
body: JSON.stringify({ args: { body: "Created by ForgeOS field test.", title: "Field test note" } }),
|
|
721
|
+
headers: authHeaders(),
|
|
722
|
+
method: "POST",
|
|
723
|
+
});
|
|
724
|
+
steps.push({
|
|
725
|
+
command: `POST ${serverUrl}/commands/createNote`,
|
|
726
|
+
durationMs: Date.now() - startedAt,
|
|
727
|
+
exitCode: create.ok && create.body?.ok === true ? 0 : 1,
|
|
728
|
+
ok: create.ok && create.body?.ok === true,
|
|
729
|
+
status: create.status,
|
|
730
|
+
traceId: create.body?.traceId,
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
const list = await fetchJson(`${serverUrl}/queries/listNotes`, {
|
|
734
|
+
body: JSON.stringify({ args: {} }),
|
|
735
|
+
headers: authHeaders(),
|
|
736
|
+
method: "POST",
|
|
737
|
+
});
|
|
738
|
+
const notes = Array.isArray(list.body?.result) ? list.body.result : [];
|
|
739
|
+
steps.push({
|
|
740
|
+
command: `POST ${serverUrl}/queries/listNotes`,
|
|
741
|
+
durationMs: Date.now() - startedAt,
|
|
742
|
+
exitCode: list.ok && notes.some((note) => note.title === "Field test note") ? 0 : 1,
|
|
743
|
+
ok: list.ok && notes.some((note) => note.title === "Field test note"),
|
|
744
|
+
status: list.status,
|
|
745
|
+
traceId: list.body?.traceId,
|
|
746
|
+
});
|
|
747
|
+
} else if (template === "b2b-support-web") {
|
|
748
|
+
const create = await fetchJson(`${serverUrl}/commands/createTicket`, {
|
|
749
|
+
body: JSON.stringify({ args: { body: "Created by ForgeOS field test.", title: "Field test ticket" } }),
|
|
750
|
+
headers: authHeaders(),
|
|
751
|
+
method: "POST",
|
|
752
|
+
});
|
|
753
|
+
steps.push({
|
|
754
|
+
command: `POST ${serverUrl}/commands/createTicket`,
|
|
755
|
+
durationMs: Date.now() - startedAt,
|
|
756
|
+
exitCode: create.ok && create.body?.ok === true ? 0 : 1,
|
|
757
|
+
ok: create.ok && create.body?.ok === true,
|
|
758
|
+
status: create.status,
|
|
759
|
+
traceId: create.body?.traceId,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
const list = await fetchJson(`${serverUrl}/queries/listTickets`, {
|
|
763
|
+
body: JSON.stringify({ args: {} }),
|
|
764
|
+
headers: authHeaders(),
|
|
765
|
+
method: "POST",
|
|
766
|
+
});
|
|
767
|
+
const tickets = Array.isArray(list.body?.result) ? list.body.result : [];
|
|
768
|
+
steps.push({
|
|
769
|
+
command: `POST ${serverUrl}/queries/listTickets`,
|
|
770
|
+
durationMs: Date.now() - startedAt,
|
|
771
|
+
exitCode: list.ok && tickets.some((ticket) => ticket.title === "Field test ticket") ? 0 : 1,
|
|
772
|
+
ok: list.ok && tickets.some((ticket) => ticket.title === "Field test ticket"),
|
|
773
|
+
status: list.status,
|
|
774
|
+
traceId: list.body?.traceId,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return {
|
|
779
|
+
ok: steps.every((step) => step.ok),
|
|
780
|
+
serverUrl,
|
|
781
|
+
webUrl,
|
|
782
|
+
steps: steps.map(compactStep),
|
|
783
|
+
stderr: compactText(stderr),
|
|
784
|
+
stdout: compactText(stdout),
|
|
785
|
+
};
|
|
786
|
+
} finally {
|
|
787
|
+
await stopProcessTree(child);
|
|
788
|
+
await new Promise((resolveClose) => {
|
|
789
|
+
const timer = setTimeout(resolveClose, 5000);
|
|
790
|
+
child.once("close", () => {
|
|
791
|
+
clearTimeout(timer);
|
|
792
|
+
resolveClose();
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function windowsBatchTarget(command, args) {
|
|
799
|
+
if (process.platform !== "win32" || !command.endsWith(".cmd")) {
|
|
800
|
+
return { args, command };
|
|
801
|
+
}
|
|
802
|
+
return {
|
|
803
|
+
command: process.env.ComSpec ?? "cmd.exe",
|
|
804
|
+
args: ["/d", "/c", command, ...args],
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function fieldCase({ appRoot, authProbes, forgeSpec, install, packageManager, runtimeProbes, template, timeoutMs, uiProbes }) {
|
|
809
|
+
const appName = `${template}-${packageManager}-field`.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
810
|
+
const appDir = join(appRoot, appName);
|
|
811
|
+
const steps = [];
|
|
812
|
+
let uiErgonomics;
|
|
813
|
+
const forgeArgs = [
|
|
814
|
+
join(repoRoot, "bin", "forge.mjs"),
|
|
815
|
+
"new",
|
|
816
|
+
appName,
|
|
817
|
+
"--template",
|
|
818
|
+
template,
|
|
819
|
+
"--package-manager",
|
|
820
|
+
packageManager,
|
|
821
|
+
"--forge-spec",
|
|
822
|
+
forgeSpec,
|
|
823
|
+
"--no-git",
|
|
824
|
+
...(install ? ["--install"] : ["--no-install"]),
|
|
825
|
+
];
|
|
826
|
+
steps.push(await runCommand(process.execPath, forgeArgs, { cwd: appRoot, timeoutMs }));
|
|
827
|
+
|
|
828
|
+
if (install) {
|
|
829
|
+
const pm = commandName(packageManager);
|
|
830
|
+
steps.push(await runCommand(pm, packageScriptArgs(packageManager, "generate"), { cwd: appDir, timeoutMs }));
|
|
831
|
+
if (authProbes) {
|
|
832
|
+
steps.push(
|
|
833
|
+
await runCommand(pm, packageScriptArgs(packageManager, "forge", ["add", "auth", "workos", "--json"]), {
|
|
834
|
+
cwd: appDir,
|
|
835
|
+
timeoutMs,
|
|
836
|
+
}),
|
|
837
|
+
);
|
|
838
|
+
steps.push(await runCommand(pm, packageScriptArgs(packageManager, "generate"), { cwd: appDir, timeoutMs }));
|
|
839
|
+
steps.push(
|
|
840
|
+
await runCommand(pm, packageScriptArgs(packageManager, "forge", ["authmd", "generate", "--json"]), {
|
|
841
|
+
cwd: appDir,
|
|
842
|
+
timeoutMs,
|
|
843
|
+
}),
|
|
844
|
+
);
|
|
845
|
+
steps.push(
|
|
846
|
+
await runCommand(pm, packageScriptArgs(packageManager, "forge", ["authmd", "check", "--json"]), {
|
|
847
|
+
cwd: appDir,
|
|
848
|
+
timeoutMs,
|
|
849
|
+
}),
|
|
850
|
+
);
|
|
851
|
+
steps.push(
|
|
852
|
+
await runCommand(pm, packageScriptArgs(packageManager, "forge", ["workos", "doctor", "--json"]), {
|
|
853
|
+
cwd: appDir,
|
|
854
|
+
timeoutMs,
|
|
855
|
+
}),
|
|
856
|
+
);
|
|
857
|
+
steps.push(
|
|
858
|
+
await runCommand(
|
|
859
|
+
pm,
|
|
860
|
+
packageScriptArgs(packageManager, "forge", ["workos", "seed", "--file", "workos-seed.yml", "--dry-run", "--json"]),
|
|
861
|
+
{ cwd: appDir, timeoutMs },
|
|
862
|
+
),
|
|
863
|
+
);
|
|
864
|
+
steps.push(
|
|
865
|
+
await runCommand(
|
|
866
|
+
pm,
|
|
867
|
+
packageScriptArgs(packageManager, "forge", ["workos", "prove", "--file", "workos-seed.yml", "--json"]),
|
|
868
|
+
{ cwd: appDir, timeoutMs },
|
|
869
|
+
),
|
|
870
|
+
);
|
|
871
|
+
steps.push(
|
|
872
|
+
await runCommand(pm, packageScriptArgs(packageManager, "forge", ["auth", "prove", "--scenario", "multi-tenant", "--json"]), {
|
|
873
|
+
cwd: appDir,
|
|
874
|
+
timeoutMs,
|
|
875
|
+
}),
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
steps.push(
|
|
879
|
+
await runCommand(pm, packageScriptArgs(packageManager, "forge", ["dev", "--once", "--json"]), {
|
|
880
|
+
cwd: appDir,
|
|
881
|
+
timeoutMs,
|
|
882
|
+
}),
|
|
883
|
+
);
|
|
884
|
+
steps.push(
|
|
885
|
+
await runCommand(
|
|
886
|
+
pm,
|
|
887
|
+
packageScriptArgs(packageManager, "forge", ["verify", "--smoke", "--json", "--script-timeout-ms", String(timeoutMs)]),
|
|
888
|
+
{ cwd: appDir, timeoutMs },
|
|
889
|
+
),
|
|
890
|
+
);
|
|
891
|
+
if (uiProbes) {
|
|
892
|
+
const ergonomics = await runCommand(
|
|
893
|
+
pm,
|
|
894
|
+
packageScriptArgs(packageManager, "forge", ["inspect", "ui", "--ergonomics", "--json"]),
|
|
895
|
+
{ cwd: appDir, timeoutMs, allowFailure: true },
|
|
896
|
+
);
|
|
897
|
+
steps.push(ergonomics);
|
|
898
|
+
uiErgonomics = summarizeUiErgonomics(ergonomics);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (runtimeProbes) {
|
|
902
|
+
const runtime = await runRuntimeProbes({ appDir, authProbes, packageManager, template, timeoutMs, uiProbes });
|
|
903
|
+
steps.push(...runtime.steps);
|
|
904
|
+
return {
|
|
905
|
+
appDir,
|
|
906
|
+
ok: steps.every((step) => step.ok),
|
|
907
|
+
packageManager,
|
|
908
|
+
runtime,
|
|
909
|
+
steps: steps.map(compactStep),
|
|
910
|
+
template,
|
|
911
|
+
uiErgonomics,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return {
|
|
917
|
+
appDir,
|
|
918
|
+
ok: steps.every((step) => step.ok),
|
|
919
|
+
packageManager,
|
|
920
|
+
steps: steps.map(compactStep),
|
|
921
|
+
template,
|
|
922
|
+
uiErgonomics,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function main() {
|
|
927
|
+
const args = parseArgs(process.argv.slice(2));
|
|
928
|
+
const cases = args.templates.flatMap((template) =>
|
|
929
|
+
args.packageManagers.map((packageManager) => ({ packageManager, template })),
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
if (args.dryRun) {
|
|
933
|
+
const plan = {
|
|
934
|
+
authProbes: args.authProbes,
|
|
935
|
+
cases,
|
|
936
|
+
forgeSpec: args.forgeSpec,
|
|
937
|
+
install: args.install,
|
|
938
|
+
ok: true,
|
|
939
|
+
runtimeProbes: args.runtimeProbes,
|
|
940
|
+
uiProbes: args.uiProbes,
|
|
941
|
+
timeoutMs: args.timeoutMs,
|
|
942
|
+
};
|
|
943
|
+
console.log(args.json ? JSON.stringify(plan, null, 2) : `Planned ${cases.length} ForgeOS field test case(s).`);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const appRoot = await mkdtemp(join(tmpdir(), "forgeos-field-"));
|
|
948
|
+
const results = [];
|
|
949
|
+
try {
|
|
950
|
+
for (const testCase of cases) {
|
|
951
|
+
const exists = await commandExists(testCase.packageManager);
|
|
952
|
+
if (!exists) {
|
|
953
|
+
results.push({ ...testCase, ok: true, skipped: true, reason: `${testCase.packageManager} not found on PATH` });
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
results.push(
|
|
957
|
+
await fieldCase({
|
|
958
|
+
appRoot,
|
|
959
|
+
authProbes: args.authProbes,
|
|
960
|
+
forgeSpec: args.forgeSpec,
|
|
961
|
+
install: args.install,
|
|
962
|
+
packageManager: testCase.packageManager,
|
|
963
|
+
runtimeProbes: args.runtimeProbes,
|
|
964
|
+
uiProbes: args.uiProbes,
|
|
965
|
+
template: testCase.template,
|
|
966
|
+
timeoutMs: args.timeoutMs,
|
|
967
|
+
}),
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
} finally {
|
|
971
|
+
if (!args.keep) {
|
|
972
|
+
await rm(appRoot, { force: true, maxRetries: 8, recursive: true, retryDelay: 250 });
|
|
973
|
+
} else {
|
|
974
|
+
await access(appRoot).catch(() => undefined);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const summary = {
|
|
979
|
+
appRoot: args.keep ? appRoot : undefined,
|
|
980
|
+
authProbes: args.authProbes,
|
|
981
|
+
forgeSpec: args.forgeSpec,
|
|
982
|
+
install: args.install,
|
|
983
|
+
ok: results.every((result) => result.ok),
|
|
984
|
+
results,
|
|
985
|
+
runtimeProbes: args.runtimeProbes,
|
|
986
|
+
uiProbes: args.uiProbes,
|
|
987
|
+
};
|
|
988
|
+
if (args.writeReport) {
|
|
989
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
990
|
+
const reportPath = resolve(args.writeReport);
|
|
991
|
+
await mkdir(dirname(reportPath), { recursive: true });
|
|
992
|
+
await writeFile(reportPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
|
993
|
+
}
|
|
994
|
+
console.log(args.json ? JSON.stringify(summary, null, 2) : humanSummary(summary));
|
|
995
|
+
if (!summary.ok) process.exitCode = 1;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function humanSummary(summary) {
|
|
999
|
+
const lines = ["ForgeOS field test"];
|
|
1000
|
+
for (const result of summary.results) {
|
|
1001
|
+
const status = result.skipped ? "SKIP" : result.ok ? "PASS" : "FAIL";
|
|
1002
|
+
const runtime = result.runtime?.serverUrl ? ` runtime=${result.runtime.serverUrl}` : "";
|
|
1003
|
+
lines.push(`${status} ${result.template} ${result.packageManager}${runtime}`);
|
|
1004
|
+
}
|
|
1005
|
+
return lines.join("\n");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
main().catch((error) => {
|
|
1009
|
+
const result = error.result ? `\n${JSON.stringify(error.result, null, 2)}` : "";
|
|
1010
|
+
console.error(`${error.message}${result}`);
|
|
1011
|
+
process.exitCode = 1;
|
|
1012
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.
|
|
1
|
+
{"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.44","releaseId":"forgeos@0.1.0-alpha.44+unknown","schemaVersion":"0.1.0"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @forge-generated generator=0.1.0-alpha.
|
|
1
|
+
// @forge-generated generator=0.1.0-alpha.44 input=3c44e5f9f6fd4afe42accec6d73bca4f36f188ed9de5fb11f4a890d3020fe2bc content=3d89cc8170a2affb58421e25ec58882655d6ea2399efe65a822d99e1afa1ddb5
|
|
2
2
|
export const releaseManifest = {
|
|
3
3
|
"defaultProvider": "local",
|
|
4
4
|
"diagnostics": [],
|
|
@@ -19,7 +19,7 @@ export const releaseManifest = {
|
|
|
19
19
|
"custom"
|
|
20
20
|
],
|
|
21
21
|
"packageName": "forgeos",
|
|
22
|
-
"packageVersion": "0.1.0-alpha.
|
|
23
|
-
"releaseId": "forgeos@0.1.0-alpha.
|
|
22
|
+
"packageVersion": "0.1.0-alpha.44",
|
|
23
|
+
"releaseId": "forgeos@0.1.0-alpha.44+unknown",
|
|
24
24
|
"schemaVersion": "0.1.0"
|
|
25
25
|
} as const;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { runNewCommand, type NewPackageManager, type NewTemplateName } from "./new.ts";
|
|
5
6
|
import { normalizeForgeCliCommandsInValue } from "../workspace/forge-cli.ts";
|
|
6
7
|
|
|
@@ -42,8 +43,14 @@ export interface FieldTestCommandResult {
|
|
|
42
43
|
exitCode: 0 | 1;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
function
|
|
46
|
-
|
|
46
|
+
function scriptCandidates(workspaceRoot: string): string[] {
|
|
47
|
+
const localScript = join(workspaceRoot, "scripts", "field-test-forgeos.mjs");
|
|
48
|
+
const packageScript = join(dirname(fileURLToPath(import.meta.url)), "../../../scripts/field-test-forgeos.mjs");
|
|
49
|
+
return Array.from(new Set([localScript, packageScript]));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function scriptPath(workspaceRoot: string): string | null {
|
|
53
|
+
return scriptCandidates(workspaceRoot).find((candidate) => existsSync(candidate)) ?? null;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
function compact(text: string, limit = 12000): string {
|
|
@@ -395,14 +402,14 @@ async function createFieldTestApp(options: FieldTestCommandOptions): Promise<Fie
|
|
|
395
402
|
|
|
396
403
|
function runHarness(options: FieldTestCommandOptions): FieldTestCommandResult {
|
|
397
404
|
const script = scriptPath(options.workspaceRoot);
|
|
398
|
-
if (!
|
|
405
|
+
if (!script) {
|
|
399
406
|
return {
|
|
400
407
|
schemaVersion: "0.1.0",
|
|
401
408
|
ok: false,
|
|
402
409
|
kind: "field-test",
|
|
403
410
|
action: "run",
|
|
404
|
-
data: { error: "field-test harness script not found",
|
|
405
|
-
nextActions: ["run from the ForgeOS framework checkout
|
|
411
|
+
data: { error: "field-test harness script not found", searched: scriptCandidates(options.workspaceRoot) },
|
|
412
|
+
nextActions: ["upgrade forgeos to a version that includes scripts/field-test-forgeos.mjs", "run from the ForgeOS framework checkout with npm run field:test"],
|
|
406
413
|
exitCode: 1,
|
|
407
414
|
};
|
|
408
415
|
}
|
package/src/forge/ui/index.ts
CHANGED
|
@@ -420,8 +420,11 @@ function hasSeedExperience(text: string): boolean {
|
|
|
420
420
|
}
|
|
421
421
|
|
|
422
422
|
function hasAutomaticSeedRecovery(text: string): boolean {
|
|
423
|
-
|
|
424
|
-
|
|
423
|
+
const effectBlocks = text.match(/useEffect\s*\([\s\S]{0,5000}?\n\s*\}\s*,\s*\[/gi) ?? [];
|
|
424
|
+
return effectBlocks.some((block) =>
|
|
425
|
+
/\b(runSeed[A-Za-z0-9_]*|seed[A-Za-z0-9_]*\.run|seedWorkspace\.run|seedVendorAccessDemo\.run)\b/.test(block) &&
|
|
426
|
+
/\b(length\s*===\s*0|No\s+\w+|empty|tenantSeedState|workspace\s+is\s+empty|first[-\s]?run)\b/i.test(block),
|
|
427
|
+
);
|
|
425
428
|
}
|
|
426
429
|
|
|
427
430
|
function findFormWithoutLabel(text: string): boolean {
|
package/src/forge/version.ts
CHANGED