@fiodos/cli 0.1.0 → 0.1.2
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/README.md +17 -0
- package/package.json +1 -1
- package/src/index.js +81 -4
- package/src/postWireTest.js +3 -3
- package/src/renderProbe.js +68 -22
- package/src/renderProbeVue.js +12 -8
- package/src/verifyWire.js +7 -7
- package/src/wireHandlers.js +179 -180
- package/src/writeEnv.js +230 -0
package/README.md
CHANGED
|
@@ -43,6 +43,23 @@ npx @fiodos/cli analyze . --publish --api-key fyd_xxx --no-llm
|
|
|
43
43
|
npx @fiodos/cli analyze . --publish --api-key fyd_xxx --api-url http://localhost:8000
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
### Tras publicar: que el orbe NO falle en silencio
|
|
47
|
+
|
|
48
|
+
El error más común es publicar con una key/URL y correr la app con OTRA: el orbe
|
|
49
|
+
pide su manifest al sitio equivocado, no lo encuentra y se renderiza invisible.
|
|
50
|
+
Para evitarlo, tras `--publish` el CLI:
|
|
51
|
+
|
|
52
|
+
1. **Re-lee el manifest** con la MISMA key+URL con la que publicó y confirma que
|
|
53
|
+
el orbe lo encontrará, imprimiendo la key+URL exactas que tu app debe usar.
|
|
54
|
+
2. En web, **ofrece escribir esa key+URL en tu `.env`** (`.env.local` en Next,
|
|
55
|
+
`.env` en Vite) para que runtime y publicación coincidan. Nunca sobrescribe
|
|
56
|
+
un valor distinto en silencio: avisa y pregunta. Desactívalo con
|
|
57
|
+
`--no-write-env`.
|
|
58
|
+
|
|
59
|
+
Y en **desarrollo**, si aun así el orbe no puede montarse (key rechazada, sin
|
|
60
|
+
manifest, backend inalcanzable), el SDK pinta un aviso visible con el motivo
|
|
61
|
+
exacto donde iría el orbe (en producción sigue invisible).
|
|
62
|
+
|
|
46
63
|
> Alternativa SIN IA — instalación manual: el flujo base de cualquier SDK
|
|
47
64
|
> siempre funciona y no requiere IA ni clave de proveedor:
|
|
48
65
|
> `npm install @fiodos/react @fiodos/core` y montar `<FiodosAgent/>` (el
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiodos/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Fiodos CLI — analyzes your app's source code and generates the in-app voice-agent manifest, then wires the orb. Powers `npx @fiodos/cli analyze`.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
package/src/index.js
CHANGED
|
@@ -31,8 +31,18 @@
|
|
|
31
31
|
* After analysis, a web target is OFFERED automatic wiring of <FiodosAgent/>
|
|
32
32
|
* into the app's root layout. It asks before editing your code; on "no" (or a
|
|
33
33
|
* non-interactive shell) it prints the ready-to-paste snippet instead.
|
|
34
|
-
* --yes
|
|
35
|
-
* --no-wire
|
|
34
|
+
* --yes accept the wiring/env without the prompt (CI / express)
|
|
35
|
+
* --no-wire skip wiring entirely (analysis/publish unaffected)
|
|
36
|
+
* --no-write-env skip the offer to align the app's .env with the published
|
|
37
|
+
* key/URL (see below)
|
|
38
|
+
*
|
|
39
|
+
* Avoiding the silent "orb fetches the wrong backend" failure:
|
|
40
|
+
* After --publish the CLI (1) re-fetches the manifest with the SAME key+URL to
|
|
41
|
+
* confirm the orb will find it, printing the exact key/URL your app must use,
|
|
42
|
+
* and (2) on web, OFFERS to write that key/URL into the app's env file
|
|
43
|
+
* (.env.local for Next, .env for Vite) so the runtime matches what you
|
|
44
|
+
* published. It never overwrites a differing value silently — it warns and
|
|
45
|
+
* asks first. Disable with --no-write-env.
|
|
36
46
|
*
|
|
37
47
|
* AI key — HYBRID model:
|
|
38
48
|
* DEFAULT (hosted): the AI analysis runs on the Fiodos backend with FIODOS'S
|
|
@@ -82,6 +92,7 @@ const { analyzeWithAI, correctActionWiring, DEFAULT_MODEL, resolveModel } = requ
|
|
|
82
92
|
const { verifyManifest } = require('./verify');
|
|
83
93
|
const { wireWebOrb, reportWireResult } = require('./wireWeb');
|
|
84
94
|
const { wireHandlers, reportHandlerResult } = require('./wireHandlers');
|
|
95
|
+
const { offerWriteEnv } = require('./writeEnv');
|
|
85
96
|
|
|
86
97
|
function arg(flag, fallback) {
|
|
87
98
|
const i = process.argv.indexOf(flag);
|
|
@@ -129,7 +140,7 @@ function fyodosTermColors() {
|
|
|
129
140
|
|
|
130
141
|
/** Internal diagnostics (tokens, cost, paths…) — only with --verbose. */
|
|
131
142
|
function isVerbose() {
|
|
132
|
-
return process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
143
|
+
return process.argv.includes('--verbose') || process.argv.includes('-v') || process.argv.includes('--debug');
|
|
133
144
|
}
|
|
134
145
|
|
|
135
146
|
function logDev(...args) {
|
|
@@ -356,9 +367,27 @@ async function main() {
|
|
|
356
367
|
// their app, so the onboarding never needs a separate manual step. Mobile is
|
|
357
368
|
// wired by its own installer and is intentionally left untouched here.
|
|
358
369
|
if (platform === 'web') {
|
|
359
|
-
const { apiUrl } = resolveApiTarget();
|
|
370
|
+
const { apiKey, apiUrl } = resolveApiTarget();
|
|
360
371
|
const assumeYes = process.argv.includes('--yes') || process.argv.includes('--wire-yes');
|
|
361
372
|
const noWire = process.argv.includes('--no-wire');
|
|
373
|
+
|
|
374
|
+
// ── 5b. Offer to align the app's .env with the published key/URL (consent).
|
|
375
|
+
// Removes the second manual copy of the key — the root cause of the silent
|
|
376
|
+
// "orb fetches the wrong backend" failure. Skipped with --no-write-env.
|
|
377
|
+
if (process.argv.includes('--publish') && !process.argv.includes('--no-write-env')) {
|
|
378
|
+
try {
|
|
379
|
+
await offerWriteEnv({
|
|
380
|
+
appRoot: path.resolve(appRoot),
|
|
381
|
+
apiKey,
|
|
382
|
+
apiUrl,
|
|
383
|
+
defaultApiUrl: DEFAULT_API_URL,
|
|
384
|
+
assumeYes,
|
|
385
|
+
log: logUser,
|
|
386
|
+
});
|
|
387
|
+
} catch (e) {
|
|
388
|
+
logDev(`[write-env] skipped: ${e.message}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
362
391
|
// --no-orb-wire skips ONLY the orb mount (useful to test handler wiring in
|
|
363
392
|
// isolation without modifying the app's entrypoint / package.json).
|
|
364
393
|
if (!process.argv.includes('--no-orb-wire')) {
|
|
@@ -658,9 +687,57 @@ async function publishManifest(manifest, meta, analysisToken = null) {
|
|
|
658
687
|
const body = await res.json();
|
|
659
688
|
logUser(`Published to your project — ${body.routes} routes, ${body.actions} actions`);
|
|
660
689
|
logDev(`[publish] OK — ${body.routes} routes, ${body.actions} actions received at ${body.receivedAt}`);
|
|
690
|
+
|
|
691
|
+
// ── Self-check: confirm the orb will actually FIND this manifest ───────────
|
|
692
|
+
// Re-fetch it the way the SDK does at runtime (GET /v1/client/manifest with
|
|
693
|
+
// the SAME key + URL). This catches the silent-failure setup BEFORE the
|
|
694
|
+
// developer is left wondering why the orb never appears, and prints the exact
|
|
695
|
+
// key/URL their app must use so they can compare with their .env.
|
|
696
|
+
await verifyOrbCanFetch({ apiKey, apiUrl });
|
|
697
|
+
|
|
661
698
|
logUser('Open the dashboard → Agent settings → "Reload analysis" to review');
|
|
662
699
|
}
|
|
663
700
|
|
|
701
|
+
/**
|
|
702
|
+
* Verify the just-published manifest is retrievable through the public client
|
|
703
|
+
* endpoint with the same credentials, and tell the developer EXACTLY which key
|
|
704
|
+
* and URL their running app must use. Never hard-fails the publish (the publish
|
|
705
|
+
* already succeeded); a failed re-fetch is surfaced as an actionable warning.
|
|
706
|
+
*/
|
|
707
|
+
async function verifyOrbCanFetch({ apiKey, apiUrl }) {
|
|
708
|
+
try {
|
|
709
|
+
const res = await fetch(`${apiUrl}/v1/client/manifest`, {
|
|
710
|
+
method: 'GET',
|
|
711
|
+
headers: { 'x-api-key': apiKey },
|
|
712
|
+
});
|
|
713
|
+
if (res.ok) {
|
|
714
|
+
const data = await res.json().catch(() => null);
|
|
715
|
+
if (data && data.manifest) {
|
|
716
|
+
logUser(
|
|
717
|
+
`✓ Verified: your orb will find this manifest using API key ${apiKey.slice(0, 12)}… ` +
|
|
718
|
+
`against ${apiUrl}.`,
|
|
719
|
+
);
|
|
720
|
+
logUser(
|
|
721
|
+
'Make sure your app uses this SAME key and URL in its .env ' +
|
|
722
|
+
'(NEXT_PUBLIC_FYODOS_API_KEY / VITE_FYODOS_API_KEY, and the API URL).',
|
|
723
|
+
);
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
logUser(
|
|
728
|
+
`⚠︎ Published, but could not re-read the manifest from ${apiUrl} (HTTP ${res.status}). ` +
|
|
729
|
+
'Check that your app uses this same key and URL.',
|
|
730
|
+
);
|
|
731
|
+
} catch (e) {
|
|
732
|
+
logDev(`[verify] re-fetch failed: ${e.message}`);
|
|
733
|
+
logUser(
|
|
734
|
+
`⚠︎ Published, but could not reach ${apiUrl} to verify. ` +
|
|
735
|
+
'Confirm your app points to this same URL and key.',
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
|
|
664
741
|
main().catch((e) => {
|
|
665
742
|
console.error(e);
|
|
666
743
|
process.exit(1);
|
package/src/postWireTest.js
CHANGED
|
@@ -61,13 +61,13 @@ async function runPostWireTest(appRoot, opts = {}) {
|
|
|
61
61
|
ok: true,
|
|
62
62
|
stage: 'skipped-no-deps',
|
|
63
63
|
command: '(none)',
|
|
64
|
-
output: 'node_modules
|
|
64
|
+
output: 'node_modules missing: could not run the build (install dependencies to verify it).',
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
const pkg = readPkg(appRoot);
|
|
68
68
|
const chosen = chooseCommand(appRoot, pkg);
|
|
69
69
|
if (!chosen) {
|
|
70
|
-
return { ok: true, stage: 'skipped-no-deps', command: '(none)', output: 'no
|
|
70
|
+
return { ok: true, stage: 'skipped-no-deps', command: '(none)', output: 'no build/typecheck script.' };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
const res = spawnSync('npm', chosen.args, {
|
|
@@ -79,7 +79,7 @@ async function runPostWireTest(appRoot, opts = {}) {
|
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
if (res.error && res.error.code === 'ETIMEDOUT') {
|
|
82
|
-
return { ok: false, stage: 'timeout', command: chosen.label, output: `build
|
|
82
|
+
return { ok: false, stage: 'timeout', command: chosen.label, output: `build exceeded ${Math.round(timeoutMs / 1000)}s` };
|
|
83
83
|
}
|
|
84
84
|
if (res.error) {
|
|
85
85
|
return { ok: false, stage: 'error', command: chosen.label, output: String(res.error.message || res.error) };
|
package/src/renderProbe.js
CHANGED
|
@@ -43,6 +43,39 @@ function loadJsdom() {
|
|
|
43
43
|
try { return require('jsdom'); } catch { return null; }
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Detailed technical output (jsdom/React internals) only when asked. */
|
|
47
|
+
function isVerboseProbe() {
|
|
48
|
+
return process.argv.includes('--verbose') || process.argv.includes('-v') || process.argv.includes('--debug');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Capture (swallow) noisy global console output during a probe — React error
|
|
53
|
+
* boundary logs ("The above error occurred in <X>", "Consider adding an error
|
|
54
|
+
* boundary…"), uncaught-error dumps, etc. These come from rendering the user's
|
|
55
|
+
* real pages in a bare jsdom (no router/providers) and are internal noise, not
|
|
56
|
+
* actionable user information. The captured lines are returned for diagnostics.
|
|
57
|
+
*
|
|
58
|
+
* No-op under --verbose/--debug so power users can still see the raw detail.
|
|
59
|
+
*/
|
|
60
|
+
function captureConsole() {
|
|
61
|
+
if (isVerboseProbe()) return { captured: [], restore() {} };
|
|
62
|
+
const captured = [];
|
|
63
|
+
const saved = {
|
|
64
|
+
log: console.log, error: console.error, warn: console.warn, info: console.info, debug: console.debug,
|
|
65
|
+
};
|
|
66
|
+
const sink = (...a) => {
|
|
67
|
+
try { captured.push(a.map((x) => (typeof x === 'string' ? x : String(x))).join(' ')); } catch { /* ignore */ }
|
|
68
|
+
};
|
|
69
|
+
console.log = sink; console.error = sink; console.warn = sink; console.info = sink; console.debug = sink;
|
|
70
|
+
return {
|
|
71
|
+
captured,
|
|
72
|
+
restore() {
|
|
73
|
+
console.log = saved.log; console.error = saved.error; console.warn = saved.warn;
|
|
74
|
+
console.info = saved.info; console.debug = saved.debug;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
46
79
|
/** esbuild plugin that empties style/asset imports (works on old esbuild that
|
|
47
80
|
* lacks the 'empty' loader). Side-effect CSS imports become no-ops. */
|
|
48
81
|
function emptyAssetsPlugin() {
|
|
@@ -100,7 +133,7 @@ async function probeReactEffect(appRoot, entry, ctx) {
|
|
|
100
133
|
const esbuild = loadEsbuild(appRoot);
|
|
101
134
|
const jsdomMod = loadJsdom();
|
|
102
135
|
if (!esbuild || !jsdomMod) {
|
|
103
|
-
return { status: 'unverifiable', detail: `
|
|
136
|
+
return { status: 'unverifiable', detail: `could not set up the headless render (${!esbuild ? 'esbuild' : 'jsdom'} not available); wiring applied, real effect not automatically verifiable — test by hand` };
|
|
104
137
|
}
|
|
105
138
|
const fyodosDirAbs = path.join(appRoot, ctx.fyodosDirRel);
|
|
106
139
|
const componentAbs = path.join(appRoot, entry.bridge.file);
|
|
@@ -118,6 +151,7 @@ async function probeReactEffect(appRoot, entry, ctx) {
|
|
|
118
151
|
`export const probe = { React: ReactNS.default || ReactNS, createRoot, flushSync, Comp, registry: fyodosGeneratedRegistries };\n`;
|
|
119
152
|
|
|
120
153
|
let restore = () => {};
|
|
154
|
+
let cap = { restore() {} };
|
|
121
155
|
try {
|
|
122
156
|
fs.writeFileSync(harnessAbs, harness);
|
|
123
157
|
await esbuild.build({
|
|
@@ -134,12 +168,14 @@ async function probeReactEffect(appRoot, entry, ctx) {
|
|
|
134
168
|
absWorkingDir: fyodosDirAbs,
|
|
135
169
|
});
|
|
136
170
|
|
|
171
|
+
// Silence React/jsdom render noise from here through unmount (see captureConsole).
|
|
172
|
+
cap = captureConsole();
|
|
137
173
|
restore = installDom(jsdomMod, { storageKeys: ctx.storageKeys, kind: ctx.kind });
|
|
138
174
|
delete require.cache[outAbs];
|
|
139
175
|
const mod = require(outAbs);
|
|
140
176
|
const { React, createRoot, flushSync, Comp, registry } = mod.probe;
|
|
141
177
|
const Component = pickComponent(Comp);
|
|
142
|
-
if (!Component) return { status: 'unverifiable', detail: `
|
|
178
|
+
if (!Component) return { status: 'unverifiable', detail: `did not find a mountable React component exported by '${entry.bridge.file}' — real effect not verifiable, test by hand` };
|
|
143
179
|
|
|
144
180
|
const container = document.createElement('div');
|
|
145
181
|
document.body.appendChild(container);
|
|
@@ -150,7 +186,7 @@ async function probeReactEffect(appRoot, entry, ctx) {
|
|
|
150
186
|
await settle();
|
|
151
187
|
flushSync(() => {});
|
|
152
188
|
} catch (err) {
|
|
153
|
-
return { status: 'unverifiable', detail: `
|
|
189
|
+
return { status: 'unverifiable', detail: `the component could not be rendered in isolation (${short(err)}); real effect not automatically verifiable — test by hand` };
|
|
154
190
|
}
|
|
155
191
|
|
|
156
192
|
const beforeHTML = captureSnapshot(container);
|
|
@@ -162,22 +198,23 @@ async function probeReactEffect(appRoot, entry, ctx) {
|
|
|
162
198
|
// Do NOT fire the effect; we proved it mounts and the handler resolves to a
|
|
163
199
|
// live bridge function. Verify the bridge has the method, nothing more.
|
|
164
200
|
try { root.unmount(); } catch {}
|
|
165
|
-
return { status: 'unverifiable', detail: '
|
|
201
|
+
return { status: 'unverifiable', detail: 'confirmation action: its effect is not fired in the test render (security); wiring verified up to the confirmation point' };
|
|
166
202
|
}
|
|
167
203
|
|
|
168
204
|
let invokeErr = null;
|
|
169
205
|
try { await handler(ctx.params); } catch (e) { invokeErr = e; }
|
|
170
206
|
await settle();
|
|
171
207
|
try { flushSync(() => {}); } catch {}
|
|
172
|
-
if (invokeErr) { try { root.unmount(); } catch {} return { status: 'fail', detail: `
|
|
208
|
+
if (invokeErr) { try { root.unmount(); } catch {} return { status: 'fail', detail: `invoking the handler, the real app threw: ${short(invokeErr)}` }; }
|
|
173
209
|
|
|
174
210
|
const afterHTML = captureSnapshot(container);
|
|
175
211
|
const afterText = container.textContent || '';
|
|
176
212
|
try { root.unmount(); } catch {}
|
|
177
213
|
return decide(ctx.kind, { beforeHTML, beforeText, afterHTML, afterText });
|
|
178
214
|
} catch (err) {
|
|
179
|
-
return { status: 'unverifiable', detail: `
|
|
215
|
+
return { status: 'unverifiable', detail: `could not prepare the test render (${short(err)}); real effect not automatically verifiable — test by hand` };
|
|
180
216
|
} finally {
|
|
217
|
+
try { cap.restore(); } catch {}
|
|
181
218
|
try { fs.existsSync(harnessAbs) && fs.rmSync(harnessAbs); } catch {}
|
|
182
219
|
try { fs.existsSync(outAbs) && fs.rmSync(outAbs); } catch {}
|
|
183
220
|
try { restore(); } catch {}
|
|
@@ -212,22 +249,22 @@ function decide(kind, snap) {
|
|
|
212
249
|
const sentinelDisappeared = beforeText.includes(SENTINEL) && !afterText.includes(SENTINEL);
|
|
213
250
|
|
|
214
251
|
if (kind === 'add') {
|
|
215
|
-
if (sentinelAppeared) return { status: 'effect-pass', detail: `
|
|
216
|
-
if (htmlChanged) return { status: 'effect-pass', detail: '
|
|
217
|
-
return { status: 'fail', detail: '
|
|
252
|
+
if (sentinelAppeared) return { status: 'effect-pass', detail: `after invoking the action, "${SENTINEL}" appears in the rendered UI (the item was really added)` };
|
|
253
|
+
if (htmlChanged) return { status: 'effect-pass', detail: 'the UI changed after invoking the action (effect observed in the render)' };
|
|
254
|
+
return { status: 'fail', detail: 'the action was invoked but the UI did not change nor did the sent value appear (it produces no effect)' };
|
|
218
255
|
}
|
|
219
256
|
if (kind === 'delete') {
|
|
220
|
-
if (sentinelDisappeared) return { status: 'effect-pass', detail: '
|
|
221
|
-
if (htmlChanged) return { status: 'effect-pass', detail: '
|
|
222
|
-
return { status: 'unverifiable', detail: 'no
|
|
257
|
+
if (sentinelDisappeared) return { status: 'effect-pass', detail: 'the seeded item disappeared from the UI after invoking the action (it was really deleted)' };
|
|
258
|
+
if (htmlChanged) return { status: 'effect-pass', detail: 'the UI changed after invoking the delete action' };
|
|
259
|
+
return { status: 'unverifiable', detail: 'no change observed on delete (maybe there was no seeded item in isolation); wiring applied, effect not confirmed — test by hand' };
|
|
223
260
|
}
|
|
224
261
|
if (kind === 'toggle') {
|
|
225
|
-
if (htmlChanged) return { status: 'effect-pass', detail: '
|
|
226
|
-
return { status: 'unverifiable', detail: 'no
|
|
262
|
+
if (htmlChanged) return { status: 'effect-pass', detail: 'the UI changed after invoking the action (item state toggled)' };
|
|
263
|
+
return { status: 'unverifiable', detail: 'no change observed on toggle (maybe no seeded item in isolation); wiring applied, effect not confirmed — test by hand' };
|
|
227
264
|
}
|
|
228
265
|
// ui / other
|
|
229
|
-
if (htmlChanged) return { status: 'effect-pass', detail: '
|
|
230
|
-
return { status: 'unverifiable', detail: '
|
|
266
|
+
if (htmlChanged) return { status: 'effect-pass', detail: 'the component UI changed after invoking the action' };
|
|
267
|
+
return { status: 'unverifiable', detail: 'the action produced no observable change in the isolated render; wiring applied, effect not confirmed — test by hand' };
|
|
231
268
|
}
|
|
232
269
|
|
|
233
270
|
function short(err) {
|
|
@@ -240,8 +277,17 @@ function short(err) {
|
|
|
240
277
|
* toggle actions have something to act on.
|
|
241
278
|
*/
|
|
242
279
|
function installDom(jsdomMod, { storageKeys = [], kind } = {}) {
|
|
243
|
-
const { JSDOM } = jsdomMod;
|
|
244
|
-
|
|
280
|
+
const { JSDOM, VirtualConsole } = jsdomMod;
|
|
281
|
+
// A VirtualConsole that does NOT forward to the real console: jsdom's default
|
|
282
|
+
// pipes page errors (uncaught exceptions → "reportException", long stack
|
|
283
|
+
// traces) to our stderr. That is internal noise from rendering the user's
|
|
284
|
+
// pages in a bare DOM. We swallow it (or forward only under --verbose).
|
|
285
|
+
let virtualConsole;
|
|
286
|
+
try {
|
|
287
|
+
virtualConsole = new VirtualConsole();
|
|
288
|
+
if (isVerboseProbe()) virtualConsole.sendTo(console);
|
|
289
|
+
} catch { virtualConsole = undefined; }
|
|
290
|
+
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/', pretendToBeVisual: true, virtualConsole });
|
|
245
291
|
const { window } = dom;
|
|
246
292
|
// Seed storage BEFORE the component hydrates (only useful for delete/toggle).
|
|
247
293
|
if (kind === 'delete' || kind === 'toggle') {
|
|
@@ -319,15 +365,15 @@ async function probeComponentEffect(appRoot, entry, opts = {}) {
|
|
|
319
365
|
|
|
320
366
|
const withTimeout = (p) => Promise.race([
|
|
321
367
|
p,
|
|
322
|
-
new Promise((resolve) => setTimeout(() => resolve({ status: 'unverifiable', detail: '
|
|
368
|
+
new Promise((resolve) => setTimeout(() => resolve({ status: 'unverifiable', detail: 'the test render exceeded the time limit (possible loop/timer in the component); real effect not automatically verifiable — test by hand' }), opts.timeoutMs || 25000)),
|
|
323
369
|
]);
|
|
324
370
|
|
|
325
371
|
if (framework.includes('react')) return withTimeout(probeReactEffect(appRoot, entry, ctx));
|
|
326
372
|
if (framework.includes('vue')) return withTimeout(require('./renderProbeVue').probeVueEffect(appRoot, entry, ctx));
|
|
327
373
|
if (framework.includes('angular')) {
|
|
328
|
-
return { status: 'unverifiable', detail: 'Angular:
|
|
374
|
+
return { status: 'unverifiable', detail: 'Angular: headless component rendering with TestBed is not reliable outside ng test; wiring applied and verified by compilation, real effect not automatically verifiable here — test with `ng test` or by hand' };
|
|
329
375
|
}
|
|
330
|
-
return { status: 'unverifiable', detail: `framework '${framework}'
|
|
376
|
+
return { status: 'unverifiable', detail: `framework '${framework}' has no render-probe; real effect not automatically verifiable` };
|
|
331
377
|
}
|
|
332
378
|
|
|
333
|
-
module.exports = { probeComponentEffect, actionKind, decide, SENTINEL, SEED_ID, installDom, pickComponent, detectStorageKeys, probeParams, captureSnapshot, short, loadEsbuild, loadJsdom, reqFrom, emptyAssetsPlugin };
|
|
379
|
+
module.exports = { probeComponentEffect, actionKind, decide, SENTINEL, SEED_ID, installDom, pickComponent, detectStorageKeys, probeParams, captureSnapshot, short, loadEsbuild, loadJsdom, reqFrom, emptyAssetsPlugin, captureConsole, isVerboseProbe };
|
package/src/renderProbeVue.js
CHANGED
|
@@ -17,7 +17,7 @@ const os = require('os');
|
|
|
17
17
|
const path = require('path');
|
|
18
18
|
const crypto = require('crypto');
|
|
19
19
|
|
|
20
|
-
const { decide, installDom, captureSnapshot, short, loadEsbuild, loadJsdom, reqFrom, emptyAssetsPlugin } = require('./renderProbe');
|
|
20
|
+
const { decide, installDom, captureSnapshot, short, loadEsbuild, loadJsdom, reqFrom, emptyAssetsPlugin, captureConsole } = require('./renderProbe');
|
|
21
21
|
|
|
22
22
|
function vuePlugin(compilerSfc) {
|
|
23
23
|
return {
|
|
@@ -52,7 +52,7 @@ async function probeVueEffect(appRoot, entry, ctx) {
|
|
|
52
52
|
const compilerSfc = reqFrom(appRoot, '@vue/compiler-sfc');
|
|
53
53
|
if (!esbuild || !jsdomMod || !compilerSfc) {
|
|
54
54
|
const miss = !esbuild ? 'esbuild' : !jsdomMod ? 'jsdom' : '@vue/compiler-sfc';
|
|
55
|
-
return { status: 'unverifiable', detail: `
|
|
55
|
+
return { status: 'unverifiable', detail: `could not set up the Vue headless render (${miss} not available); wiring applied, real effect not automatically verifiable — test by hand` };
|
|
56
56
|
}
|
|
57
57
|
const fyodosDirAbs = path.join(appRoot, ctx.fyodosDirRel);
|
|
58
58
|
const componentAbs = path.join(appRoot, entry.bridge.file);
|
|
@@ -66,6 +66,7 @@ async function probeVueEffect(appRoot, entry, ctx) {
|
|
|
66
66
|
`export const probe = { createApp, nextTick, Component, registry: fyodosGeneratedRegistries };\n`;
|
|
67
67
|
|
|
68
68
|
let restore = () => {};
|
|
69
|
+
let cap = { restore() {} };
|
|
69
70
|
try {
|
|
70
71
|
fs.writeFileSync(harnessAbs, harness);
|
|
71
72
|
await esbuild.build({
|
|
@@ -86,11 +87,13 @@ async function probeVueEffect(appRoot, entry, ctx) {
|
|
|
86
87
|
absWorkingDir: fyodosDirAbs,
|
|
87
88
|
});
|
|
88
89
|
|
|
90
|
+
// Silence Vue/jsdom render noise from here through unmount.
|
|
91
|
+
cap = captureConsole();
|
|
89
92
|
restore = installDom(jsdomMod, { storageKeys: ctx.storageKeys, kind: ctx.kind });
|
|
90
93
|
delete require.cache[outAbs];
|
|
91
94
|
const mod = require(outAbs);
|
|
92
95
|
const { createApp, nextTick, Component, registry } = mod.probe;
|
|
93
|
-
if (!Component) return { status: 'unverifiable', detail: `'${entry.bridge.file}'
|
|
96
|
+
if (!Component) return { status: 'unverifiable', detail: `'${entry.bridge.file}' does not export a mountable Vue component — real effect not verifiable, test by hand` };
|
|
94
97
|
|
|
95
98
|
const container = document.createElement('div');
|
|
96
99
|
document.body.appendChild(container);
|
|
@@ -102,31 +105,32 @@ async function probeVueEffect(appRoot, entry, ctx) {
|
|
|
102
105
|
await nextTick();
|
|
103
106
|
} catch (err) {
|
|
104
107
|
try { app && app.unmount(); } catch {}
|
|
105
|
-
return { status: 'unverifiable', detail: `
|
|
108
|
+
return { status: 'unverifiable', detail: `the Vue component could not be mounted in isolation (${short(err)}); real effect not automatically verifiable — test by hand` };
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
const beforeHTML = captureSnapshot(container);
|
|
109
112
|
const beforeText = container.textContent || '';
|
|
110
113
|
const handler = registry.handlers[entry.handler];
|
|
111
|
-
if (typeof handler !== 'function') { try { app.unmount(); } catch {} return { status: 'fail', detail: `
|
|
114
|
+
if (typeof handler !== 'function') { try { app.unmount(); } catch {} return { status: 'fail', detail: `the registry does not expose the handler '${entry.handler}'` }; }
|
|
112
115
|
|
|
113
116
|
if (ctx.sensitive) {
|
|
114
117
|
try { app.unmount(); } catch {}
|
|
115
|
-
return { status: 'unverifiable', detail: '
|
|
118
|
+
return { status: 'unverifiable', detail: 'confirmation action: its effect is not fired in the test render (security); wiring verified up to the confirmation point' };
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
let invokeErr = null;
|
|
119
122
|
try { await handler(ctx.params); } catch (e) { invokeErr = e; }
|
|
120
123
|
await nextTick();
|
|
121
|
-
if (invokeErr) { try { app.unmount(); } catch {} return { status: 'fail', detail: `
|
|
124
|
+
if (invokeErr) { try { app.unmount(); } catch {} return { status: 'fail', detail: `invoking the handler, the real app threw: ${short(invokeErr)}` }; }
|
|
122
125
|
|
|
123
126
|
const afterHTML = captureSnapshot(container);
|
|
124
127
|
const afterText = container.textContent || '';
|
|
125
128
|
try { app.unmount(); } catch {}
|
|
126
129
|
return decide(ctx.kind, { beforeHTML, beforeText, afterHTML, afterText });
|
|
127
130
|
} catch (err) {
|
|
128
|
-
return { status: 'unverifiable', detail: `
|
|
131
|
+
return { status: 'unverifiable', detail: `could not prepare the Vue test render (${short(err)}); real effect not automatically verifiable — test by hand` };
|
|
129
132
|
} finally {
|
|
133
|
+
try { cap.restore(); } catch {}
|
|
130
134
|
try { fs.existsSync(harnessAbs) && fs.rmSync(harnessAbs); } catch {}
|
|
131
135
|
try { fs.existsSync(outAbs) && fs.rmSync(outAbs); } catch {}
|
|
132
136
|
try { restore(); } catch {}
|
package/src/verifyWire.js
CHANGED
|
@@ -145,11 +145,11 @@ async function probeDexie(appRoot, entry, fyodosDirRel) {
|
|
|
145
145
|
const op = dbMatch[3];
|
|
146
146
|
const grew = after > before;
|
|
147
147
|
const changed = after !== before || /update|put|delete/.test(op);
|
|
148
|
-
if (op === 'delete') return { status: 'effect-pass', detail: `delete
|
|
149
|
-
if (grew || changed) return { status: 'effect-pass', detail: `'${table}'
|
|
150
|
-
return { status: 'fail', detail: `
|
|
148
|
+
if (op === 'delete') return { status: 'effect-pass', detail: `delete ran on the table '${table}'` };
|
|
149
|
+
if (grew || changed) return { status: 'effect-pass', detail: `'${table}' changed (${before}→${after}) running the real expression` };
|
|
150
|
+
return { status: 'fail', detail: `the expression ran but '${table}' did not change (${before}→${after})` };
|
|
151
151
|
} catch (err) {
|
|
152
|
-
return { status: 'fail', detail: `
|
|
152
|
+
return { status: 'fail', detail: `the expression threw: ${err && err.message ? err.message : String(err)}` };
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
|
|
@@ -200,16 +200,16 @@ async function probeEffect(appRoot, entry, opts = {}) {
|
|
|
200
200
|
sensitive: opts.sensitive || entry.requireConfirmation,
|
|
201
201
|
});
|
|
202
202
|
} catch (err) {
|
|
203
|
-
return { status: 'unverifiable', detail: `render-probe
|
|
203
|
+
return { status: 'unverifiable', detail: `render-probe not available (${err && err.message}); real effect not automatically verifiable — test by hand` };
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
if (opts.sensitive || entry.requireConfirmation) {
|
|
207
|
-
return { status: 'skipped', detail: '
|
|
207
|
+
return { status: 'skipped', detail: 'confirmation action: its effect is not fired in the simulation (security)' };
|
|
208
208
|
}
|
|
209
209
|
// Module strategy: try the Dexie data-layer probe.
|
|
210
210
|
const dexie = await probeDexie(appRoot, entry, opts.fyodosDirRel);
|
|
211
211
|
if (dexie) return dexie;
|
|
212
|
-
return { status: 'skipped', detail: '
|
|
212
|
+
return { status: 'skipped', detail: 'module target not safely simulable in-process; verified by compilation' };
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
module.exports = { probeEffect, compileRegressed, extractErrorLines, readDexieStores };
|