@opencraw/studio 0.0.6 → 0.0.7
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/dist/index.esm.js +536 -103
- package/dist/src/explain-why/index.d.ts +5 -0
- package/dist/src/explain-why/item-hint.algorithm.d.ts +13 -0
- package/dist/src/explain-why/why-sentence.mapper.d.ts +48 -0
- package/dist/src/explain-why/why.use-case.d.ts +26 -0
- package/dist/src/index.d.ts +4 -3
- package/dist/src/sample-run/index.d.ts +3 -1
- package/dist/src/sample-run/last-run.store.d.ts +17 -0
- package/dist/src/sample-run/run-sample.use-case.d.ts +12 -6
- package/dist/src/sample-run/sample-run-record.contract.d.ts +30 -1
- package/dist/src/studio-api/command.contract.d.ts +59 -0
- package/dist/src/studio-api/event.contract.d.ts +41 -1
- package/dist/src/studio-api/index.d.ts +6 -4
- package/dist/src/studio-api/why-view.contract.d.ts +16 -0
- package/dist/src/studio-server/explain-why.handler.d.ts +14 -0
- package/dist/src/studio-server/workspace.store.d.ts +4 -3
- package/dist/ui/assets/index-DV8i4Zza.js +13 -0
- package/dist/ui/index.html +1 -1
- package/package.json +2 -2
- package/dist/ui/assets/index-BfbrsfCv.js +0 -13
package/dist/index.esm.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createServer } from 'node:http';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { randomBytes, createHash } from 'node:crypto';
|
|
6
|
-
import {
|
|
6
|
+
import { readRecipeSource, parseInputRecipe, parseOutputRecipe, bindRecipeSet, memorySink, createCrawler, traceLine, resolveMissingPolicy, BrowserClient, HttpClient, NODE_ID_ATTRIBUTE, countMatches, RecipeBindingError, RecipeValidationError } from '@opencraw/core';
|
|
7
7
|
import { load } from 'cheerio';
|
|
8
8
|
import { writeFile } from 'node:fs/promises';
|
|
9
9
|
import { existsSync, createReadStream } from 'node:fs';
|
|
@@ -119,7 +119,32 @@ const inferSelectorCommandSchema = z.object({
|
|
|
119
119
|
path: z.string().min(1),
|
|
120
120
|
nodeIds: z.union([onePickedNodeSchema, twoPickedNodesSchema])
|
|
121
121
|
});
|
|
122
|
-
|
|
122
|
+
/** A missing value in an emitted record, by its position in the last sample run's records (the order they streamed in, matching the UI's own `records` list). */
|
|
123
|
+
const missingWhyTargetSchema = z.object({
|
|
124
|
+
kind: z.literal('missing'),
|
|
125
|
+
recipeId: z.string().min(1),
|
|
126
|
+
recordIndex: z.int().nonnegative(),
|
|
127
|
+
field: z.string().min(1)
|
|
128
|
+
});
|
|
129
|
+
/** A rejected record, by its position in the last sample run's `rejectedRecords`. */
|
|
130
|
+
const rejectedWhyTargetSchema = z.object({
|
|
131
|
+
kind: z.literal('rejected'),
|
|
132
|
+
recipeId: z.string().min(1),
|
|
133
|
+
rejectedIndex: z.int().nonnegative()
|
|
134
|
+
});
|
|
135
|
+
const whyTargetSchema = z.discriminatedUnion('kind', [missingWhyTargetSchema, rejectedWhyTargetSchema]);
|
|
136
|
+
/**
|
|
137
|
+
* Explains one missing or rejected value from the recipe's last sample run
|
|
138
|
+
* (`explain-why.use-case.ts`, issue #92's Why? tab): the mapping trace and
|
|
139
|
+
* scope snapshot `run-sample` already kept, the step that bound the value's
|
|
140
|
+
* source id and whether it ran, was skipped or found nothing, and the
|
|
141
|
+
* missing-value policy that applied.
|
|
142
|
+
*/
|
|
143
|
+
const explainWhyCommandSchema = z.object({
|
|
144
|
+
type: z.literal('explain-why'),
|
|
145
|
+
target: whyTargetSchema
|
|
146
|
+
});
|
|
147
|
+
const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema, explainWhyCommandSchema]);
|
|
123
148
|
|
|
124
149
|
/** What `take-snapshot` answers with: the rewritten document ready for the content pane's sandboxed iframe (studio plan §3.1, issue #91). */
|
|
125
150
|
const snapshotViewSchema = z.object({
|
|
@@ -186,12 +211,32 @@ const traceLineEventSchema = z.object({
|
|
|
186
211
|
type: z.literal('trace-line'),
|
|
187
212
|
line: z.string()
|
|
188
213
|
});
|
|
189
|
-
/** One
|
|
214
|
+
/** One transform's effect within a field's trace: its `op` and the value it returned. */
|
|
215
|
+
const traceStepSchema = z.object({
|
|
216
|
+
op: z.string(),
|
|
217
|
+
value: z.unknown()
|
|
218
|
+
});
|
|
219
|
+
/** One entry of a mapping trace field: what a rule's `from` read, and the value after each transform, in order (`@opencraw/core`'s `FieldTrace`). */
|
|
220
|
+
const fieldTraceSchema = z.object({
|
|
221
|
+
from: z.unknown(),
|
|
222
|
+
steps: z.array(traceStepSchema)
|
|
223
|
+
});
|
|
224
|
+
/** One record a sample run emitted: `scope` (what it was mapped from) and `mapping` (each field's trace) ride along, so the Record tab (#92) can show a transform chain's real values without a second round trip. */
|
|
190
225
|
const recordEventSchema = z.object({
|
|
191
226
|
type: z.literal('record'),
|
|
192
227
|
recipeId: z.string(),
|
|
193
228
|
key: z.string().nullable(),
|
|
194
|
-
data: z.record(z.string(), z.unknown())
|
|
229
|
+
data: z.record(z.string(), z.unknown()),
|
|
230
|
+
scope: z.record(z.string(), z.unknown()).optional(),
|
|
231
|
+
mapping: z.record(z.string(), fieldTraceSchema).optional()
|
|
232
|
+
});
|
|
233
|
+
/** One record a sample run rejected (a field's `skip-record` policy on a missing or uncoercible value). */
|
|
234
|
+
const recordRejectedEventSchema = z.object({
|
|
235
|
+
type: z.literal('record-rejected'),
|
|
236
|
+
recipeId: z.string(),
|
|
237
|
+
field: z.string(),
|
|
238
|
+
reason: z.string(),
|
|
239
|
+
scope: z.record(z.string(), z.unknown()).optional()
|
|
195
240
|
});
|
|
196
241
|
/** A sample run ended: how many records, and why it stopped when it did not finish on its own. */
|
|
197
242
|
const runFinishedEventSchema = z.object({
|
|
@@ -208,7 +253,7 @@ const runFinishedEventSchema = z.object({
|
|
|
208
253
|
const workspaceChangedEventSchema = z.object({
|
|
209
254
|
type: z.literal('workspace-changed')
|
|
210
255
|
});
|
|
211
|
-
const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, runFinishedEventSchema, workspaceChangedEventSchema]);
|
|
256
|
+
const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, recordRejectedEventSchema, runFinishedEventSchema, workspaceChangedEventSchema]);
|
|
212
257
|
|
|
213
258
|
/** One problem found on a recipe file, with the JSON path it lives at. */
|
|
214
259
|
const recipeIssueSchema = z.object({
|
|
@@ -256,6 +301,20 @@ const startPageViewSchema = z.object({
|
|
|
256
301
|
html: z.string()
|
|
257
302
|
});
|
|
258
303
|
|
|
304
|
+
/** What `explain-why` answers with: the sentence (`why-sentence.mapper.ts`'s `buildWhySentence`), plus the structured facts a click can act on — a link back to the step that bound the value, the policy that applied. */
|
|
305
|
+
const whyViewSchema = z.object({
|
|
306
|
+
sentence: z.string(),
|
|
307
|
+
field: z.string(),
|
|
308
|
+
recipeId: z.string(),
|
|
309
|
+
outcome: z.enum(['missing', 'rejected']),
|
|
310
|
+
/** The JSON path (`steps.2.steps.0`) of the step that bound the value's source id, when one was found in the recipe. */
|
|
311
|
+
stepPath: z.string().optional(),
|
|
312
|
+
/** The missing-value policy that applied (`nullable`, `default`, `null`, `fail`, `skip-record`), when one could be resolved. */
|
|
313
|
+
policy: z.string().optional(),
|
|
314
|
+
/** The engine's own rejection reason, for a rejected record. */
|
|
315
|
+
reason: z.string().optional()
|
|
316
|
+
});
|
|
317
|
+
|
|
259
318
|
/** The cookie set on the page's own document response, so its own asset and API requests carry the token too. */
|
|
260
319
|
const TOKEN_COOKIE = 'opencraw_token';
|
|
261
320
|
/** A random token, generated once at server startup and required on every request. */
|
|
@@ -298,6 +357,457 @@ function cookieToken(header) {
|
|
|
298
357
|
return undefined;
|
|
299
358
|
}
|
|
300
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Finds and parses one input recipe of a workspace folder, and the output
|
|
362
|
+
* recipe it names.
|
|
363
|
+
*
|
|
364
|
+
* @param folder - The workspace folder.
|
|
365
|
+
* @param recipeId - The input recipe's id.
|
|
366
|
+
* @returns The parsed pair.
|
|
367
|
+
* @throws RecipeValidationError when either recipe does not parse.
|
|
368
|
+
* @throws Error when the input or its output is not in the folder.
|
|
369
|
+
*/
|
|
370
|
+
async function loadRecipePair(folder, recipeId) {
|
|
371
|
+
const documents = await readRecipeSource(folder);
|
|
372
|
+
const inputDocument = documents.find(document => kindOf(document.content) === 'input' && idOf(document.content) === recipeId);
|
|
373
|
+
if (inputDocument === undefined) throw new Error(`no input recipe "${recipeId}" in this workspace`);
|
|
374
|
+
const input = parseInputRecipe(inputDocument.content, inputDocument.source);
|
|
375
|
+
const outputDocument = documents.find(document => kindOf(document.content) === 'output' && idOf(document.content) === input.output);
|
|
376
|
+
if (outputDocument === undefined) throw new Error(`no output recipe "${input.output}" in this workspace`);
|
|
377
|
+
return {
|
|
378
|
+
input,
|
|
379
|
+
output: parseOutputRecipe(outputDocument.content, outputDocument.source)
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function kindOf(content) {
|
|
383
|
+
return content?.kind;
|
|
384
|
+
}
|
|
385
|
+
function idOf(content) {
|
|
386
|
+
return content?.id;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Runs one input recipe of a workspace folder as a sample: `debug: true` (so
|
|
391
|
+
* each `record:emit` carries its scope snapshot and mapping trace, and each
|
|
392
|
+
* `record:reject` its scope), an in-memory sink, and `budget` as the
|
|
393
|
+
* crawler's `sample` (`@opencraw/core`'s `CrawlOptions.sample`, #89). Every
|
|
394
|
+
* engine event becomes a trace line with the same `traceLine` formatter the
|
|
395
|
+
* cli's `--trace` uses; every emitted record reaches `callbacks.onRecord` as
|
|
396
|
+
* it happens, not only in the final result. Step events (`step:start`,
|
|
397
|
+
* `step:skip`, `step:retry`) are aggregated by path into `result.steps`, so
|
|
398
|
+
* `explain-why` (#92) can later say whether the step that bound a mapping's
|
|
399
|
+
* source id ran, was skipped, or retried — without a second run.
|
|
400
|
+
*
|
|
401
|
+
* @param folder - The workspace folder the recipe lives in.
|
|
402
|
+
* @param recipeId - The input recipe to run.
|
|
403
|
+
* @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
|
|
404
|
+
* @param callbacks - Where trace lines and records go as the run proceeds.
|
|
405
|
+
* @param browser - Browser launch settings for a web-mode recipe (an executable path override, headless…); the server-wide setting `studio-http.use-case.ts`'s `StudioServerOptions.browser` carries in, e.g. `OPENCRAW_CHROMIUM` in a sandbox with no full Playwright install.
|
|
406
|
+
* @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
|
|
407
|
+
* @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
|
|
408
|
+
*/
|
|
409
|
+
async function runSample(folder, recipeId, budget, callbacks, browser) {
|
|
410
|
+
const {
|
|
411
|
+
input,
|
|
412
|
+
output
|
|
413
|
+
} = await loadRecipePair(folder, recipeId);
|
|
414
|
+
const set = bindRecipeSet(output, [input]);
|
|
415
|
+
const sink = memorySink();
|
|
416
|
+
const records = [];
|
|
417
|
+
const rejectedRecords = [];
|
|
418
|
+
const steps = new Map();
|
|
419
|
+
const crawler = createCrawler({
|
|
420
|
+
sink,
|
|
421
|
+
debug: true,
|
|
422
|
+
sample: budget,
|
|
423
|
+
browser,
|
|
424
|
+
onEvent: event => {
|
|
425
|
+
const line = traceLine(event);
|
|
426
|
+
if (line !== undefined) callbacks.onTraceLine(line);
|
|
427
|
+
trackStep(steps, event);
|
|
428
|
+
if (event.type === 'record:emit') {
|
|
429
|
+
const record = {
|
|
430
|
+
key: event.key,
|
|
431
|
+
data: event.data,
|
|
432
|
+
scope: event.scope,
|
|
433
|
+
mapping: event.mapping
|
|
434
|
+
};
|
|
435
|
+
records.push(record);
|
|
436
|
+
callbacks.onRecord(record);
|
|
437
|
+
} else if (event.type === 'record:reject') {
|
|
438
|
+
const rejected = {
|
|
439
|
+
field: event.field,
|
|
440
|
+
reason: event.reason,
|
|
441
|
+
url: event.url,
|
|
442
|
+
scope: event.scope
|
|
443
|
+
};
|
|
444
|
+
rejectedRecords.push(rejected);
|
|
445
|
+
callbacks.onRejected?.(rejected);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
const result = runToResult(crawler, set, records, rejectedRecords, steps);
|
|
450
|
+
return {
|
|
451
|
+
stop: () => crawler.close(),
|
|
452
|
+
result
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Folds one `step:*` event into `steps`, by path: `skip` always wins over an
|
|
457
|
+
* earlier `ran`/`retried` outcome for the same path (a step skipped even
|
|
458
|
+
* once is worth flagging), `retry` wins over a plain `ran`, and a path seen
|
|
459
|
+
* only as `finish`/`start` (no skip, no retry) settles on `ran`. A `forEach`
|
|
460
|
+
* runs its body once per item, so the same path folds repeatedly; this is
|
|
461
|
+
* deliberately a summary across the whole sample, not a per-record trace.
|
|
462
|
+
*/
|
|
463
|
+
function trackStep(steps, event) {
|
|
464
|
+
if (event.type === 'step:start') {
|
|
465
|
+
if (!steps.has(event.path)) steps.set(event.path, {
|
|
466
|
+
path: event.path,
|
|
467
|
+
stepType: event.stepType,
|
|
468
|
+
stepId: event.stepId,
|
|
469
|
+
outcome: 'ran'
|
|
470
|
+
});
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (event.type === 'step:retry') {
|
|
474
|
+
steps.set(event.path, {
|
|
475
|
+
path: event.path,
|
|
476
|
+
stepType: event.stepType,
|
|
477
|
+
stepId: event.stepId,
|
|
478
|
+
outcome: 'retried',
|
|
479
|
+
error: event.error
|
|
480
|
+
});
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (event.type === 'step:skip') {
|
|
484
|
+
steps.set(event.path, {
|
|
485
|
+
path: event.path,
|
|
486
|
+
stepType: event.stepType,
|
|
487
|
+
stepId: event.stepId,
|
|
488
|
+
outcome: 'skipped',
|
|
489
|
+
error: event.error
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async function runToResult(crawler, set, records, rejectedRecords, steps) {
|
|
494
|
+
try {
|
|
495
|
+
const report = await crawler.run(set);
|
|
496
|
+
const recipe = report.recipes[0];
|
|
497
|
+
return {
|
|
498
|
+
recipeId: recipe.recipeId,
|
|
499
|
+
emitted: recipe.emitted,
|
|
500
|
+
rejected: recipe.rejected,
|
|
501
|
+
duplicates: recipe.duplicates,
|
|
502
|
+
durationMs: recipe.durationMs,
|
|
503
|
+
error: recipe.error,
|
|
504
|
+
stoppedBy: recipe.stoppedBy,
|
|
505
|
+
records,
|
|
506
|
+
rejectedRecords,
|
|
507
|
+
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
|
|
508
|
+
steps: [...steps.values()]
|
|
509
|
+
};
|
|
510
|
+
} finally {
|
|
511
|
+
await crawler.close();
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** @returns A fresh, empty cache. */
|
|
516
|
+
function createLastRunCache() {
|
|
517
|
+
return {
|
|
518
|
+
entries: new Map()
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/** Records a finished sample run, replacing whatever this recipe's cache entry held before. */
|
|
522
|
+
function recordLastRun(cache, recipeId, result) {
|
|
523
|
+
cache.entries.set(recipeId, {
|
|
524
|
+
recipeId,
|
|
525
|
+
result
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/** @returns The recipe's last completed sample run, or `undefined` when none has finished yet. */
|
|
529
|
+
function lastRunOf(cache, recipeId) {
|
|
530
|
+
return cache.entries.get(recipeId);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* A best-effort hint about what a missing value's bound id actually held in
|
|
535
|
+
* the scope snapshot: the keys of an object, or the length of a list — the
|
|
536
|
+
* fallback when there is no per-item DOM snapshot to compare a selector
|
|
537
|
+
* against (see `why-sentence.mapper.ts`'s doc comment for why the richer,
|
|
538
|
+
* "the item has `.price_new`" hint is out of this phase's scope).
|
|
539
|
+
*
|
|
540
|
+
* @param scope - The record's scope snapshot (`SampleRunRecord.scope`/`SampleRunRejected.scope`).
|
|
541
|
+
* @param boundId - The leading id the rule's `from` reads.
|
|
542
|
+
* @returns A short clause (no leading punctuation), or `undefined` when the id is not in scope or nothing useful can be said about it.
|
|
543
|
+
*/
|
|
544
|
+
function hintFor(scope, boundId) {
|
|
545
|
+
if (scope === undefined || boundId === undefined) return undefined;
|
|
546
|
+
const value = scope[boundId];
|
|
547
|
+
if (value === undefined) return undefined;
|
|
548
|
+
if (Array.isArray(value)) return value.length === 0 ? undefined : `"${boundId}" is a list of ${value.length}, not a single value`;
|
|
549
|
+
if (typeof value === 'object' && value !== null) {
|
|
550
|
+
const keys = Object.keys(value);
|
|
551
|
+
return keys.length === 0 ? undefined : `"${boundId}" has: ${keys.slice(0, 5).join(', ')}`;
|
|
552
|
+
}
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Builds the Why? tab's sentence (studio plan §4.4, issue #92) from the
|
|
558
|
+
* facts `why.use-case.ts` gathers: the value the mapping read (and each
|
|
559
|
+
* transform's effect on it), the step that bound its source id and whether
|
|
560
|
+
* it ran, was skipped, or bound nothing this recipe knows of, the
|
|
561
|
+
* missing-value policy that applied, and a hint about what was actually
|
|
562
|
+
* there instead.
|
|
563
|
+
*
|
|
564
|
+
* This is the scope-snapshot-only half of the design: phase 2's per-item DOM
|
|
565
|
+
* snapshot (the CSS-selector "the item has `.price_new`" hint the issue's
|
|
566
|
+
* example shows) is not wired in here — `sample-run` keeps the mapping's
|
|
567
|
+
* scope snapshot (bound ids and their values), not a per-`forEach`-item DOM
|
|
568
|
+
* capture, and building that link is a bigger integration than this phase's
|
|
569
|
+
* budget covers (see this package's `page-snapshot` slice, which captures a
|
|
570
|
+
* step's page for the content pane, not per-iteration). `hint`, when given,
|
|
571
|
+
* is instead built from the scope snapshot itself (`item-hint.algorithm.ts`).
|
|
572
|
+
*
|
|
573
|
+
* @param facts - What is known about the value.
|
|
574
|
+
* @returns One sentence, always ending in a period.
|
|
575
|
+
*/
|
|
576
|
+
function buildWhySentence(facts) {
|
|
577
|
+
return facts.outcome === 'rejected' ? rejectedSentence(facts) : missingSentence(facts);
|
|
578
|
+
}
|
|
579
|
+
function rejectedSentence(facts) {
|
|
580
|
+
const parts = [`"${facts.field}" was rejected: ${facts.reason ?? 'the engine gave no reason'}`];
|
|
581
|
+
if (facts.policy !== undefined) parts.push(` (policy: ${facts.policy})`);
|
|
582
|
+
return `${parts.join('')}.`;
|
|
583
|
+
}
|
|
584
|
+
function missingSentence(facts) {
|
|
585
|
+
const parts = [`"${facts.field}" is missing`, stepClause(facts)];
|
|
586
|
+
if (facts.policy !== undefined) parts.push(`; left ${facts.policy === 'null' ? 'null' : facts.policy} by policy`);
|
|
587
|
+
if (facts.hint !== undefined) parts.push(`; ${facts.hint}`);
|
|
588
|
+
return `${parts.join('')}.`;
|
|
589
|
+
}
|
|
590
|
+
function stepClause(facts) {
|
|
591
|
+
const boundId = facts.boundId ?? facts.field;
|
|
592
|
+
const step = facts.step;
|
|
593
|
+
if (step === undefined) return ': no step in this recipe binds its source';
|
|
594
|
+
const label = step.stepId === undefined ? describeStepType(step.stepType) : `${describeStepType(step.stepType)} "${step.stepId}"`;
|
|
595
|
+
if (step.outcome === 'not-found') return `: no step in this recipe binds "${boundId}"`;
|
|
596
|
+
if (step.outcome === 'skipped') return `: ${label} (${step.path}), which binds "${boundId}", was skipped${step.error === undefined ? '' : ` (${step.error})`}`;
|
|
597
|
+
if (step.outcome === 'retried') return `: ${label} (${step.path}), which binds "${boundId}", had to retry${step.error === undefined ? '' : ` (${step.error})`} and still found nothing for "${facts.field}"`;
|
|
598
|
+
return `: ${label} (${step.path}) ran and bound "${boundId}", but "${facts.field}" reads ${sourceOf(facts)} from it, which was empty`;
|
|
599
|
+
}
|
|
600
|
+
function sourceOf(facts) {
|
|
601
|
+
return facts.trace?.from === undefined ? 'a path' : JSON.stringify(facts.trace.from);
|
|
602
|
+
}
|
|
603
|
+
/** A step's type read as a short phrase, matching the Steps outline's own vocabulary (studio plan §4.1: "Go to…", "Read… from…"). */
|
|
604
|
+
function describeStepType(stepType) {
|
|
605
|
+
switch (stepType) {
|
|
606
|
+
case 'extract':
|
|
607
|
+
{
|
|
608
|
+
return 'the Read step';
|
|
609
|
+
}
|
|
610
|
+
case 'request':
|
|
611
|
+
{
|
|
612
|
+
return 'the request step';
|
|
613
|
+
}
|
|
614
|
+
case 'forEach':
|
|
615
|
+
{
|
|
616
|
+
return 'the loop';
|
|
617
|
+
}
|
|
618
|
+
case 'paginate':
|
|
619
|
+
{
|
|
620
|
+
return 'the pagination';
|
|
621
|
+
}
|
|
622
|
+
case 'goto':
|
|
623
|
+
{
|
|
624
|
+
return 'the Go to step';
|
|
625
|
+
}
|
|
626
|
+
default:
|
|
627
|
+
{
|
|
628
|
+
return `the ${stepType} step`;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Explains one missing or rejected value from a recipe's last sample run
|
|
635
|
+
* (issue #92's Why? tab, studio plan §4.4): the mapping trace and scope
|
|
636
|
+
* snapshot `run-sample` kept, the recipe's own rule and field for the
|
|
637
|
+
* missing-value policy, and the step that bound the value's source id — did
|
|
638
|
+
* it run, was it skipped, or does no step in this recipe bind it at all.
|
|
639
|
+
*
|
|
640
|
+
* @param folder - The workspace folder (to re-read the recipe pair: the last
|
|
641
|
+
* run only kept the run's own data, not the recipe text, and a save may
|
|
642
|
+
* have happened since).
|
|
643
|
+
* @param cache - The server's last-run cache (`sample-run`'s `LastRunCache`).
|
|
644
|
+
* @param loadPair - Loads the input/output recipe pair; injected so this
|
|
645
|
+
* stays testable without a real workspace folder (`sample-run`'s
|
|
646
|
+
* `loadRecipePair` in production).
|
|
647
|
+
* @param target - What was clicked.
|
|
648
|
+
* @returns The explanation.
|
|
649
|
+
* @throws Error when no sample run has finished for this recipe yet, or the target does not name a real record/field.
|
|
650
|
+
*/
|
|
651
|
+
async function explainWhy(folder, cache, loadPair, target) {
|
|
652
|
+
const entry = lastRunOf(cache, target.recipeId);
|
|
653
|
+
if (entry === undefined) throw new Error(`no finished sample run for "${target.recipeId}" yet — run a sample first`);
|
|
654
|
+
const {
|
|
655
|
+
input,
|
|
656
|
+
output
|
|
657
|
+
} = await loadPair(folder, target.recipeId);
|
|
658
|
+
return target.kind === 'rejected' ? explainRejected(target.recipeId, input, output, entry.result.rejectedRecords, entry.result.steps, target.rejectedIndex) : explainMissing(target.recipeId, input, output, entry.result.records, entry.result.steps, target.recordIndex, target.field);
|
|
659
|
+
}
|
|
660
|
+
function explainRejected(recipeId, input, output, rejectedRecords, steps, index) {
|
|
661
|
+
const rejected = rejectedRecords[index];
|
|
662
|
+
if (rejected === undefined) throw new Error(`no rejected record at index ${index}`);
|
|
663
|
+
const rule = ruleFor(input.mapping, rejected.field);
|
|
664
|
+
const field = fieldFor(output.fields, rejected.field);
|
|
665
|
+
const policy = field === undefined ? undefined : resolveMissingPolicy(field, output, rule);
|
|
666
|
+
const boundId = boundIdOf(rule);
|
|
667
|
+
const step = boundId === undefined ? undefined : findStep(input.steps, boundId, steps);
|
|
668
|
+
const hint = hintFor(rejected.scope, boundId);
|
|
669
|
+
const sentence = buildWhySentence({
|
|
670
|
+
field: rejected.field,
|
|
671
|
+
outcome: 'rejected',
|
|
672
|
+
reason: rejected.reason,
|
|
673
|
+
policy,
|
|
674
|
+
boundId,
|
|
675
|
+
step,
|
|
676
|
+
hint
|
|
677
|
+
});
|
|
678
|
+
return {
|
|
679
|
+
sentence,
|
|
680
|
+
field: rejected.field,
|
|
681
|
+
recipeId,
|
|
682
|
+
outcome: 'rejected',
|
|
683
|
+
reason: rejected.reason,
|
|
684
|
+
policy,
|
|
685
|
+
stepPath: step?.path
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
function explainMissing(recipeId, input, output, records, steps, index, field) {
|
|
689
|
+
const record = records[index];
|
|
690
|
+
if (record === undefined) throw new Error(`no record at index ${index}`);
|
|
691
|
+
const trace = record.mapping?.[field];
|
|
692
|
+
const rule = ruleFor(input.mapping, field);
|
|
693
|
+
const fieldSpec = fieldFor(output.fields, field);
|
|
694
|
+
const policy = fieldSpec === undefined ? undefined : resolveMissingPolicy(fieldSpec, output, rule);
|
|
695
|
+
const boundId = boundIdOf(rule);
|
|
696
|
+
const step = boundId === undefined ? undefined : findStep(input.steps, boundId, steps);
|
|
697
|
+
const hint = hintFor(record.scope, boundId);
|
|
698
|
+
const sentence = buildWhySentence({
|
|
699
|
+
field,
|
|
700
|
+
outcome: 'missing',
|
|
701
|
+
policy,
|
|
702
|
+
trace,
|
|
703
|
+
boundId,
|
|
704
|
+
step,
|
|
705
|
+
hint
|
|
706
|
+
});
|
|
707
|
+
return {
|
|
708
|
+
sentence,
|
|
709
|
+
field,
|
|
710
|
+
recipeId,
|
|
711
|
+
outcome: 'missing',
|
|
712
|
+
policy,
|
|
713
|
+
stepPath: step?.path
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The rule bound to one output field path: a direct match on `input.mapping`
|
|
718
|
+
* (its keys are themselves dotted target paths — `stock.inStock`, `tax` —
|
|
719
|
+
* `map-record.use-case.ts` writes each with `setPath`), or, for a path
|
|
720
|
+
* inside an `each` list's items (`variants[1].size`), the item's own rule
|
|
721
|
+
* inside the list rule's `fields`. Only one level of `each` nesting is
|
|
722
|
+
* resolved; a field inside a nested `each` (an `each` of `each`) is not —
|
|
723
|
+
* out of this phase's scope.
|
|
724
|
+
*/
|
|
725
|
+
function ruleFor(mapping, path) {
|
|
726
|
+
const direct = mapping[path];
|
|
727
|
+
if (direct !== undefined) return direct;
|
|
728
|
+
const match = /^([^[]+)\[\d+\]\.(.+)$/.exec(path);
|
|
729
|
+
if (match === null) return undefined;
|
|
730
|
+
const parent = mapping[match[1]];
|
|
731
|
+
return parent !== undefined && 'each' in parent ? parent.fields[match[2]] : undefined;
|
|
732
|
+
}
|
|
733
|
+
/** The output field spec at one dotted path (`stock.inStock`, `variants[1].size`), walking `object`/`array` members the same way `map-record.use-case.ts` builds them. */
|
|
734
|
+
function fieldFor(fields, path) {
|
|
735
|
+
let current = fields;
|
|
736
|
+
let field;
|
|
737
|
+
for (const token of path.split('.')) {
|
|
738
|
+
const match = /^([^[]+)(\[\d+\])?$/.exec(token);
|
|
739
|
+
if (match === null || current === undefined) return undefined;
|
|
740
|
+
field = current[match[1]];
|
|
741
|
+
if (field === undefined) return undefined;
|
|
742
|
+
current = match[2] === undefined ? field.fields : field.items?.fields;
|
|
743
|
+
}
|
|
744
|
+
return field;
|
|
745
|
+
}
|
|
746
|
+
/** The leading id a `from` rule reads (`item` of `item.description`, `price` of `price`); `undefined` for an `each` rule, a `.` (self), or a rule this run found nothing to say about. */
|
|
747
|
+
function boundIdOf(rule) {
|
|
748
|
+
if (rule === undefined || !('from' in rule)) return undefined;
|
|
749
|
+
const first = Array.isArray(rule.from) ? rule.from[0] : rule.from;
|
|
750
|
+
const match = /^([A-Z_][\w-]*)/i.exec(first);
|
|
751
|
+
return match?.[1];
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Finds the step of the recipe whose `id` or `as` (a `forEach`'s loop
|
|
755
|
+
* variable) is `boundId`, walking into `forEach`/`paginate`/`if` bodies the
|
|
756
|
+
* same way `scope-outline`'s outline does, and reports how it went over the
|
|
757
|
+
* whole sample run from `stepSummaries` (`sample-run`'s per-path summary).
|
|
758
|
+
*
|
|
759
|
+
* @returns `undefined` only when nothing in `steps` looks like a step at all; a `boundId` that names no real step still gets a fact, with `outcome: 'not-found'`.
|
|
760
|
+
*/
|
|
761
|
+
function findStep(steps, boundId, stepSummaries) {
|
|
762
|
+
const found = search(steps, boundId, 'steps');
|
|
763
|
+
if (found === undefined) return {
|
|
764
|
+
path: '',
|
|
765
|
+
stepType: '',
|
|
766
|
+
outcome: 'not-found'
|
|
767
|
+
};
|
|
768
|
+
const summary = stepSummaries.find(entry => entry.path === found.path);
|
|
769
|
+
return {
|
|
770
|
+
path: found.path,
|
|
771
|
+
stepType: found.step.type ?? 'unknown',
|
|
772
|
+
stepId: found.step.id,
|
|
773
|
+
outcome: summary?.outcome ?? 'ran',
|
|
774
|
+
error: summary?.error
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
function search(steps, boundId, prefix) {
|
|
778
|
+
for (const [index, step] of steps.entries()) {
|
|
779
|
+
const path = `${prefix}.${index}`;
|
|
780
|
+
if (step.id === boundId || step.as === boundId) return {
|
|
781
|
+
path,
|
|
782
|
+
step
|
|
783
|
+
};
|
|
784
|
+
if (step.steps !== undefined) {
|
|
785
|
+
const nested = search(step.steps, boundId, `${path}.steps`);
|
|
786
|
+
if (nested !== undefined) return nested;
|
|
787
|
+
}
|
|
788
|
+
if (step.else !== undefined) {
|
|
789
|
+
const nested = search(step.else, boundId, `${path}.else`);
|
|
790
|
+
if (nested !== undefined) return nested;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return undefined;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Handles `explain-why` (issue #92's Why? tab): builds the sentence for a
|
|
798
|
+
* missing or rejected value from the recipe's last sample run, kept in
|
|
799
|
+
* `state.lastRuns` by `handleRunSample`.
|
|
800
|
+
*
|
|
801
|
+
* @param state - The server state: `folder` must already be set (`open-workspace` first).
|
|
802
|
+
* @param command - The command.
|
|
803
|
+
* @returns The explanation.
|
|
804
|
+
* @throws Error when no workspace is open, no sample run has finished for the target's recipe, or the target does not name a real record/field.
|
|
805
|
+
*/
|
|
806
|
+
async function handleExplainWhy(state, command) {
|
|
807
|
+
if (state.folder === undefined) throw new Error('open a workspace first');
|
|
808
|
+
return explainWhy(state.folder, state.lastRuns, loadRecipePair, command.target);
|
|
809
|
+
}
|
|
810
|
+
|
|
301
811
|
/**
|
|
302
812
|
* Fetches an input recipe's start page as the engine would see it: a fresh
|
|
303
813
|
* Playwright page in web mode, core's HTTP client in api mode. Returns plain
|
|
@@ -558,100 +1068,6 @@ function keyOf(recipeId, path) {
|
|
|
558
1068
|
return `${recipeId}:${path}`;
|
|
559
1069
|
}
|
|
560
1070
|
|
|
561
|
-
/**
|
|
562
|
-
* Finds and parses one input recipe of a workspace folder, and the output
|
|
563
|
-
* recipe it names.
|
|
564
|
-
*
|
|
565
|
-
* @param folder - The workspace folder.
|
|
566
|
-
* @param recipeId - The input recipe's id.
|
|
567
|
-
* @returns The parsed pair.
|
|
568
|
-
* @throws RecipeValidationError when either recipe does not parse.
|
|
569
|
-
* @throws Error when the input or its output is not in the folder.
|
|
570
|
-
*/
|
|
571
|
-
async function loadRecipePair(folder, recipeId) {
|
|
572
|
-
const documents = await readRecipeSource(folder);
|
|
573
|
-
const inputDocument = documents.find(document => kindOf(document.content) === 'input' && idOf(document.content) === recipeId);
|
|
574
|
-
if (inputDocument === undefined) throw new Error(`no input recipe "${recipeId}" in this workspace`);
|
|
575
|
-
const input = parseInputRecipe(inputDocument.content, inputDocument.source);
|
|
576
|
-
const outputDocument = documents.find(document => kindOf(document.content) === 'output' && idOf(document.content) === input.output);
|
|
577
|
-
if (outputDocument === undefined) throw new Error(`no output recipe "${input.output}" in this workspace`);
|
|
578
|
-
return {
|
|
579
|
-
input,
|
|
580
|
-
output: parseOutputRecipe(outputDocument.content, outputDocument.source)
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
|
-
function kindOf(content) {
|
|
584
|
-
return content?.kind;
|
|
585
|
-
}
|
|
586
|
-
function idOf(content) {
|
|
587
|
-
return content?.id;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
/**
|
|
591
|
-
* Runs one input recipe of a workspace folder as a sample: `debug: true` (so
|
|
592
|
-
* each `record:emit` carries its data), an in-memory sink, and `budget` as
|
|
593
|
-
* the crawler's `sample` (`@opencraw/core`'s `CrawlOptions.sample`, #89).
|
|
594
|
-
* Every engine event becomes a trace line with the same `traceLine`
|
|
595
|
-
* formatter the cli's `--trace` uses; every emitted record reaches
|
|
596
|
-
* `callbacks.onRecord` as it happens, not only in the final result.
|
|
597
|
-
*
|
|
598
|
-
* @param folder - The workspace folder the recipe lives in.
|
|
599
|
-
* @param recipeId - The input recipe to run.
|
|
600
|
-
* @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
|
|
601
|
-
* @param callbacks - Where trace lines and records go as the run proceeds.
|
|
602
|
-
* @param browser - Browser launch settings for a web-mode recipe (an executable path override, headless…); the server-wide setting `studio-http.use-case.ts`'s `StudioServerOptions.browser` carries in, e.g. `OPENCRAW_CHROMIUM` in a sandbox with no full Playwright install.
|
|
603
|
-
* @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
|
|
604
|
-
* @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
|
|
605
|
-
*/
|
|
606
|
-
async function runSample(folder, recipeId, budget, callbacks, browser) {
|
|
607
|
-
const {
|
|
608
|
-
input,
|
|
609
|
-
output
|
|
610
|
-
} = await loadRecipePair(folder, recipeId);
|
|
611
|
-
const set = bindRecipeSet(output, [input]);
|
|
612
|
-
const sink = memorySink();
|
|
613
|
-
const crawler = createCrawler({
|
|
614
|
-
sink,
|
|
615
|
-
debug: true,
|
|
616
|
-
sample: budget,
|
|
617
|
-
browser,
|
|
618
|
-
onEvent: event => {
|
|
619
|
-
const line = traceLine(event);
|
|
620
|
-
if (line !== undefined) callbacks.onTraceLine(line);
|
|
621
|
-
if (event.type === 'record:emit') callbacks.onRecord({
|
|
622
|
-
key: event.key,
|
|
623
|
-
data: event.data
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
|
-
});
|
|
627
|
-
const result = runToResult(crawler, set, sink);
|
|
628
|
-
return {
|
|
629
|
-
stop: () => crawler.close(),
|
|
630
|
-
result
|
|
631
|
-
};
|
|
632
|
-
}
|
|
633
|
-
async function runToResult(crawler, set, sink) {
|
|
634
|
-
try {
|
|
635
|
-
const report = await crawler.run(set);
|
|
636
|
-
const recipe = report.recipes[0];
|
|
637
|
-
return {
|
|
638
|
-
recipeId: recipe.recipeId,
|
|
639
|
-
emitted: recipe.emitted,
|
|
640
|
-
rejected: recipe.rejected,
|
|
641
|
-
duplicates: recipe.duplicates,
|
|
642
|
-
durationMs: recipe.durationMs,
|
|
643
|
-
error: recipe.error,
|
|
644
|
-
stoppedBy: recipe.stoppedBy,
|
|
645
|
-
records: sink.records.map(record => ({
|
|
646
|
-
key: record.key,
|
|
647
|
-
data: record.data
|
|
648
|
-
}))
|
|
649
|
-
};
|
|
650
|
-
} finally {
|
|
651
|
-
await crawler.close();
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
1071
|
/**
|
|
656
1072
|
* Handles `fetch-start-page`: loads the named input recipe from the current
|
|
657
1073
|
* workspace and fetches its start point (see `page-snapshot`'s own caveats:
|
|
@@ -1546,11 +1962,12 @@ async function saveRecipe(path, recipe) {
|
|
|
1546
1962
|
await writeFile(path, `${JSON.stringify(recipe, null, 2)}\n`, 'utf8');
|
|
1547
1963
|
}
|
|
1548
1964
|
|
|
1549
|
-
/** @returns Fresh state for a new server instance: no workspace open, no run, no sockets, an empty snapshot cache, Playwright's own default browser. */
|
|
1965
|
+
/** @returns Fresh state for a new server instance: no workspace open, no run, no sockets, an empty snapshot cache, no last runs, Playwright's own default browser. */
|
|
1550
1966
|
function createStudioState() {
|
|
1551
1967
|
return {
|
|
1552
1968
|
sockets: new Set(),
|
|
1553
|
-
snapshots: createSnapshotCache()
|
|
1969
|
+
snapshots: createSnapshotCache(),
|
|
1970
|
+
lastRuns: createLastRunCache()
|
|
1554
1971
|
};
|
|
1555
1972
|
}
|
|
1556
1973
|
/**
|
|
@@ -1616,13 +2033,25 @@ async function handleRunSample(state, command) {
|
|
|
1616
2033
|
type: 'record',
|
|
1617
2034
|
recipeId: command.recipeId,
|
|
1618
2035
|
key: record.key,
|
|
1619
|
-
data: record.data
|
|
2036
|
+
data: record.data,
|
|
2037
|
+
scope: record.scope,
|
|
2038
|
+
mapping: record.mapping
|
|
2039
|
+
});
|
|
2040
|
+
},
|
|
2041
|
+
onRejected: rejected => {
|
|
2042
|
+
broadcast(state, {
|
|
2043
|
+
type: 'record-rejected',
|
|
2044
|
+
recipeId: command.recipeId,
|
|
2045
|
+
field: rejected.field,
|
|
2046
|
+
reason: rejected.reason,
|
|
2047
|
+
scope: rejected.scope
|
|
1620
2048
|
});
|
|
1621
2049
|
}
|
|
1622
2050
|
}, state.browser);
|
|
1623
2051
|
state.activeRun = handle;
|
|
1624
2052
|
void handle.result.then(result => {
|
|
1625
2053
|
if (state.activeRun === handle) state.activeRun = undefined;
|
|
2054
|
+
recordLastRun(state.lastRuns, command.recipeId, result);
|
|
1626
2055
|
broadcast(state, {
|
|
1627
2056
|
type: 'run-finished',
|
|
1628
2057
|
recipeId: result.recipeId,
|
|
@@ -2033,6 +2462,10 @@ function dispatch(command, state) {
|
|
|
2033
2462
|
{
|
|
2034
2463
|
return Promise.resolve(handleInferSelector(state, command));
|
|
2035
2464
|
}
|
|
2465
|
+
case 'explain-why':
|
|
2466
|
+
{
|
|
2467
|
+
return handleExplainWhy(state, command);
|
|
2468
|
+
}
|
|
2036
2469
|
}
|
|
2037
2470
|
}
|
|
2038
2471
|
function respondJson(response, status, body) {
|
|
@@ -2089,5 +2522,5 @@ async function main(argv) {
|
|
|
2089
2522
|
process.on('SIGTERM', shutdown);
|
|
2090
2523
|
}
|
|
2091
2524
|
|
|
2092
|
-
export { fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, inferSelectorCommandSchema, inferSelectorViewSchema, loadRecipePair, main, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, sentencePartSchema, snapshotViewSchema, startPageViewSchema, startStudioServer, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, takeSnapshotCommandSchema, traceLineEventSchema, verifySelectorCommandSchema, verifySelectorViewSchema, workspaceChangedEventSchema, workspaceViewSchema };
|
|
2525
|
+
export { explainWhyCommandSchema, fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, fieldTraceSchema, inferSelectorCommandSchema, inferSelectorViewSchema, loadRecipePair, main, missingWhyTargetSchema, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, recordRejectedEventSchema, rejectedWhyTargetSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, sentencePartSchema, snapshotViewSchema, startPageViewSchema, startStudioServer, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, takeSnapshotCommandSchema, traceLineEventSchema, verifySelectorCommandSchema, verifySelectorViewSchema, whyTargetSchema, whyViewSchema, workspaceChangedEventSchema, workspaceViewSchema };
|
|
2093
2526
|
//# sourceMappingURL=index.esm.js.map
|