@opencraw/studio 0.0.5 → 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 +1393 -82
- 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/page-snapshot/hidden-marks.algorithm.d.ts +23 -0
- package/dist/src/page-snapshot/index.d.ts +7 -0
- package/dist/src/page-snapshot/rewrite-document.mapper.d.ts +27 -0
- package/dist/src/page-snapshot/snapshot.store.d.ts +14 -0
- package/dist/src/page-snapshot/take-snapshot.use-case.d.ts +34 -0
- 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 +15 -7
- package/dist/src/sample-run/sample-run-record.contract.d.ts +30 -1
- package/dist/src/selector-inference/class-token.policy.d.ts +35 -0
- package/dist/src/selector-inference/dom-path.model.d.ts +73 -0
- package/dist/src/selector-inference/index.d.ts +12 -0
- package/dist/src/selector-inference/list-inference.algorithm.d.ts +43 -0
- package/dist/src/selector-inference/selector-candidates.algorithm.d.ts +22 -0
- package/dist/src/selector-inference/selector-ranking.policy.d.ts +27 -0
- package/dist/src/selector-inference/shadow-dom.error.d.ts +11 -0
- package/dist/src/selector-inference/take-kind.policy.d.ts +12 -0
- package/dist/src/studio-api/command.contract.d.ts +103 -0
- package/dist/src/studio-api/event.contract.d.ts +41 -1
- package/dist/src/studio-api/index.d.ts +12 -4
- package/dist/src/studio-api/infer-selector-view.contract.d.ts +68 -0
- package/dist/src/studio-api/snapshot-view.contract.d.ts +9 -0
- package/dist/src/studio-api/verify-selector-view.contract.d.ts +18 -0
- 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/infer-selector.handler.d.ts +18 -0
- package/dist/src/studio-server/studio-http.use-case.d.ts +3 -0
- package/dist/src/studio-server/take-snapshot.handler.d.ts +16 -0
- package/dist/src/studio-server/verify-selector.handler.d.ts +18 -0
- package/dist/src/studio-server/workspace.store.d.ts +9 -3
- package/dist/ui/assets/index-DV8i4Zza.js +13 -0
- package/dist/ui/index.html +1 -1
- package/package.json +5 -3
- package/dist/ui/assets/index-DYb-tdmr.js +0 -12
package/dist/index.esm.js
CHANGED
|
@@ -3,7 +3,8 @@ 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
|
+
import { load } from 'cheerio';
|
|
7
8
|
import { writeFile } from 'node:fs/promises';
|
|
8
9
|
import { existsSync, createReadStream } from 'node:fs';
|
|
9
10
|
|
|
@@ -82,7 +83,126 @@ const saveOutlineCommandSchema = z.object({
|
|
|
82
83
|
path: z.string().min(1),
|
|
83
84
|
outline: outlineViewSchema
|
|
84
85
|
});
|
|
85
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Captures the snapshot the content pane shows for a step (`page-snapshot`'s
|
|
88
|
+
* `take-snapshot.use-case.ts`), caching it by recipe and step path so
|
|
89
|
+
* switching the selected step does not always re-run the recipe.
|
|
90
|
+
*/
|
|
91
|
+
const takeSnapshotCommandSchema = z.object({
|
|
92
|
+
type: z.literal('take-snapshot'),
|
|
93
|
+
recipeId: z.string().min(1),
|
|
94
|
+
path: z.string().min(1)
|
|
95
|
+
});
|
|
96
|
+
/**
|
|
97
|
+
* Verifies a candidate selector against the cached snapshot and, when
|
|
98
|
+
* reachable, the live page — the studio's own selection (`@opencraw/core`'s
|
|
99
|
+
* `countMatches`), never the browser's or a guess (issue #91).
|
|
100
|
+
*/
|
|
101
|
+
const verifySelectorCommandSchema = z.object({
|
|
102
|
+
type: z.literal('verify-selector'),
|
|
103
|
+
recipeId: z.string().min(1),
|
|
104
|
+
path: z.string().min(1),
|
|
105
|
+
selector: z.string().min(1)
|
|
106
|
+
});
|
|
107
|
+
/**
|
|
108
|
+
* Turns one or two picked nodes (their `data-oc-node` ids, off the cached
|
|
109
|
+
* snapshot) into a verified selector: one id for a `Read` card's field, two
|
|
110
|
+
* for the safe item+field list shape `selector-inference`'s `inferList`
|
|
111
|
+
* builds by construction (issue #91, studio plan §3.2).
|
|
112
|
+
*/
|
|
113
|
+
const pickedNodeIdSchema = z.string().min(1);
|
|
114
|
+
const onePickedNodeSchema = z.tuple([pickedNodeIdSchema]);
|
|
115
|
+
const twoPickedNodesSchema = z.tuple([pickedNodeIdSchema, pickedNodeIdSchema]);
|
|
116
|
+
const inferSelectorCommandSchema = z.object({
|
|
117
|
+
type: z.literal('infer-selector'),
|
|
118
|
+
recipeId: z.string().min(1),
|
|
119
|
+
path: z.string().min(1),
|
|
120
|
+
nodeIds: z.union([onePickedNodeSchema, twoPickedNodesSchema])
|
|
121
|
+
});
|
|
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]);
|
|
148
|
+
|
|
149
|
+
/** What `take-snapshot` answers with: the rewritten document ready for the content pane's sandboxed iframe (studio plan §3.1, issue #91). */
|
|
150
|
+
const snapshotViewSchema = z.object({
|
|
151
|
+
html: z.string(),
|
|
152
|
+
nodeCount: z.number(),
|
|
153
|
+
baseUrl: z.string()
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* What `verify-selector` answers with: the match count against the snapshot
|
|
158
|
+
* and, when the live page could be reached, against it too (issue #91's
|
|
159
|
+
* "flag a selector that matches the snapshot but not the live page").
|
|
160
|
+
* `liveChecked` is `false` when the live page could not be fetched (offline,
|
|
161
|
+
* blocked by `allowedHosts`, a transient error) — the card shows the
|
|
162
|
+
* snapshot count alone rather than a misleading "0 on the live page".
|
|
163
|
+
*/
|
|
164
|
+
const verifySelectorViewSchema = z.object({
|
|
165
|
+
selector: z.string(),
|
|
166
|
+
snapshotMatches: z.number(),
|
|
167
|
+
liveChecked: z.boolean(),
|
|
168
|
+
liveMatches: z.number().optional(),
|
|
169
|
+
liveError: z.string().optional()
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** The candidate tiers `selector-inference`'s ranking works through, best first — see `selector-candidates.algorithm.ts`. */
|
|
173
|
+
const candidateTierSchema = z.enum(['id', 'data-attr', 'class', 'structure', 'position']);
|
|
174
|
+
/** One clicked node's field: the best selector found for it, verified against the snapshot. */
|
|
175
|
+
const fieldPickSchema = z.object({
|
|
176
|
+
selector: z.string(),
|
|
177
|
+
tier: candidateTierSchema,
|
|
178
|
+
take: z.string(),
|
|
179
|
+
matches: z.number()
|
|
180
|
+
});
|
|
181
|
+
/** A list's item shape: the best selector that matches every item, verified against the snapshot. */
|
|
182
|
+
const itemPickSchema = z.object({
|
|
183
|
+
selector: z.string(),
|
|
184
|
+
tier: candidateTierSchema,
|
|
185
|
+
matches: z.number()
|
|
186
|
+
});
|
|
187
|
+
/**
|
|
188
|
+
* What `infer-selector` answers with (issue #91, studio plan §3.2):
|
|
189
|
+
* `"field"` for one clicked node — a `Read` card's shape; `"list"` for two
|
|
190
|
+
* similar nodes — the safe `extract`(items)+`forEach`(field) shape, item and
|
|
191
|
+
* field each verified separately; `"unsupported"` when the two picks are not
|
|
192
|
+
* a coherent list (different branches, the same node twice, or shadow DOM),
|
|
193
|
+
* with a message the card can show as-is.
|
|
194
|
+
*/
|
|
195
|
+
const inferSelectorViewSchema = z.discriminatedUnion('kind', [z.object({
|
|
196
|
+
kind: z.literal('field'),
|
|
197
|
+
field: fieldPickSchema
|
|
198
|
+
}), z.object({
|
|
199
|
+
kind: z.literal('list'),
|
|
200
|
+
item: itemPickSchema,
|
|
201
|
+
field: fieldPickSchema
|
|
202
|
+
}), z.object({
|
|
203
|
+
kind: z.literal('unsupported'),
|
|
204
|
+
reason: z.string()
|
|
205
|
+
})]);
|
|
86
206
|
|
|
87
207
|
/** Why a sample run stopped before finishing on its own; mirrors `@opencraw/core`'s `RecipeReport.stoppedBy`. */
|
|
88
208
|
const stoppedBySchema = z.enum(['sample-maxRecords', 'sample-maxPages', 'sample-maxMs']);
|
|
@@ -91,12 +211,32 @@ const traceLineEventSchema = z.object({
|
|
|
91
211
|
type: z.literal('trace-line'),
|
|
92
212
|
line: z.string()
|
|
93
213
|
});
|
|
94
|
-
/** 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. */
|
|
95
225
|
const recordEventSchema = z.object({
|
|
96
226
|
type: z.literal('record'),
|
|
97
227
|
recipeId: z.string(),
|
|
98
228
|
key: z.string().nullable(),
|
|
99
|
-
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()
|
|
100
240
|
});
|
|
101
241
|
/** A sample run ended: how many records, and why it stopped when it did not finish on its own. */
|
|
102
242
|
const runFinishedEventSchema = z.object({
|
|
@@ -113,7 +253,7 @@ const runFinishedEventSchema = z.object({
|
|
|
113
253
|
const workspaceChangedEventSchema = z.object({
|
|
114
254
|
type: z.literal('workspace-changed')
|
|
115
255
|
});
|
|
116
|
-
const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, runFinishedEventSchema, workspaceChangedEventSchema]);
|
|
256
|
+
const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, recordRejectedEventSchema, runFinishedEventSchema, workspaceChangedEventSchema]);
|
|
117
257
|
|
|
118
258
|
/** One problem found on a recipe file, with the JSON path it lives at. */
|
|
119
259
|
const recipeIssueSchema = z.object({
|
|
@@ -161,6 +301,20 @@ const startPageViewSchema = z.object({
|
|
|
161
301
|
html: z.string()
|
|
162
302
|
});
|
|
163
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
|
+
|
|
164
318
|
/** The cookie set on the page's own document response, so its own asset and API requests carry the token too. */
|
|
165
319
|
const TOKEN_COOKIE = 'opencraw_token';
|
|
166
320
|
/** A random token, generated once at server startup and required on every request. */
|
|
@@ -203,61 +357,6 @@ function cookieToken(header) {
|
|
|
203
357
|
return undefined;
|
|
204
358
|
}
|
|
205
359
|
|
|
206
|
-
/**
|
|
207
|
-
* Fetches an input recipe's start page as the engine would see it: a fresh
|
|
208
|
-
* Playwright page in web mode, core's HTTP client in api mode. Returns plain
|
|
209
|
-
* text: the page's HTML, or a readable rendering of a non-HTML api response.
|
|
210
|
-
* No rewriting, no script stripping, no node ids yet: that is phase 2's
|
|
211
|
-
* `page-snapshot` proper (studio plan, #91). Provisional for phase 0 in two
|
|
212
|
-
* ways, both worth knowing before relying on it: it opens a bare page
|
|
213
|
-
* without running the recipe's own steps first, so a start point behind a
|
|
214
|
-
* `session.bootstrap` login shows what an anonymous visitor sees; and it
|
|
215
|
-
* launches a fresh browser or HTTP context per call, one page then closes
|
|
216
|
-
* it, rather than reusing whatever the last sample run had open.
|
|
217
|
-
*
|
|
218
|
-
* @param input - The input recipe; only `mode` and the first `start` point are used.
|
|
219
|
-
* @param browser - Browser launch settings for web mode (an executable path override, headless…).
|
|
220
|
-
* @returns The page's content as plain text.
|
|
221
|
-
* @throws Error when the recipe has no start point.
|
|
222
|
-
*/
|
|
223
|
-
async function fetchStartPage(input, browser) {
|
|
224
|
-
const point = input.start[0];
|
|
225
|
-
if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
|
|
226
|
-
return input.mode === 'web' ? fetchWeb(point.url, browser) : fetchApi(point.url);
|
|
227
|
-
}
|
|
228
|
-
async function fetchWeb(url, browser) {
|
|
229
|
-
const client = await BrowserClient.launch(browser);
|
|
230
|
-
try {
|
|
231
|
-
const session = await client.newSession();
|
|
232
|
-
try {
|
|
233
|
-
await session.page.goto(url);
|
|
234
|
-
return await session.page.content();
|
|
235
|
-
} finally {
|
|
236
|
-
await session.close();
|
|
237
|
-
}
|
|
238
|
-
} finally {
|
|
239
|
-
await client.close();
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
async function fetchApi(url) {
|
|
243
|
-
const client = await HttpClient.open({});
|
|
244
|
-
try {
|
|
245
|
-
const response = await client.send({
|
|
246
|
-
url
|
|
247
|
-
});
|
|
248
|
-
return textOf(response.body);
|
|
249
|
-
} finally {
|
|
250
|
-
await client.dispose();
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
/** Every body kind the studio can show as text today; others get a placeholder rather than a crash. */
|
|
254
|
-
function textOf(body) {
|
|
255
|
-
if (body.kind === 'html') return body.html;
|
|
256
|
-
if (body.kind === 'text') return body.text;
|
|
257
|
-
if (body.kind === 'json') return JSON.stringify(body.data, null, 2);
|
|
258
|
-
return `[${body.kind} document: the content pane shows HTML and JSON responses for now]`;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
360
|
/**
|
|
262
361
|
* Finds and parses one input recipe of a workspace folder, and the output
|
|
263
362
|
* recipe it names.
|
|
@@ -289,46 +388,109 @@ function idOf(content) {
|
|
|
289
388
|
|
|
290
389
|
/**
|
|
291
390
|
* Runs one input recipe of a workspace folder as a sample: `debug: true` (so
|
|
292
|
-
* each `record:emit` carries its
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
* `
|
|
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.
|
|
297
400
|
*
|
|
298
401
|
* @param folder - The workspace folder the recipe lives in.
|
|
299
402
|
* @param recipeId - The input recipe to run.
|
|
300
403
|
* @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
|
|
301
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.
|
|
302
406
|
* @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
|
|
303
407
|
* @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
|
|
304
408
|
*/
|
|
305
|
-
async function runSample(folder, recipeId, budget, callbacks) {
|
|
409
|
+
async function runSample(folder, recipeId, budget, callbacks, browser) {
|
|
306
410
|
const {
|
|
307
411
|
input,
|
|
308
412
|
output
|
|
309
413
|
} = await loadRecipePair(folder, recipeId);
|
|
310
414
|
const set = bindRecipeSet(output, [input]);
|
|
311
415
|
const sink = memorySink();
|
|
416
|
+
const records = [];
|
|
417
|
+
const rejectedRecords = [];
|
|
418
|
+
const steps = new Map();
|
|
312
419
|
const crawler = createCrawler({
|
|
313
420
|
sink,
|
|
314
421
|
debug: true,
|
|
315
422
|
sample: budget,
|
|
423
|
+
browser,
|
|
316
424
|
onEvent: event => {
|
|
317
425
|
const line = traceLine(event);
|
|
318
426
|
if (line !== undefined) callbacks.onTraceLine(line);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
+
}
|
|
323
447
|
}
|
|
324
448
|
});
|
|
325
|
-
const result = runToResult(crawler, set,
|
|
449
|
+
const result = runToResult(crawler, set, records, rejectedRecords, steps);
|
|
326
450
|
return {
|
|
327
451
|
stop: () => crawler.close(),
|
|
328
452
|
result
|
|
329
453
|
};
|
|
330
454
|
}
|
|
331
|
-
|
|
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) {
|
|
332
494
|
try {
|
|
333
495
|
const report = await crawler.run(set);
|
|
334
496
|
const recipe = report.recipes[0];
|
|
@@ -340,16 +502,572 @@ async function runToResult(crawler, set, sink) {
|
|
|
340
502
|
durationMs: recipe.durationMs,
|
|
341
503
|
error: recipe.error,
|
|
342
504
|
stoppedBy: recipe.stoppedBy,
|
|
343
|
-
records
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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()]
|
|
347
509
|
};
|
|
348
510
|
} finally {
|
|
349
511
|
await crawler.close();
|
|
350
512
|
}
|
|
351
513
|
}
|
|
352
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
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Fetches an input recipe's start page as the engine would see it: a fresh
|
|
813
|
+
* Playwright page in web mode, core's HTTP client in api mode. Returns plain
|
|
814
|
+
* text: the page's HTML, or a readable rendering of a non-HTML api response.
|
|
815
|
+
* No rewriting, no script stripping, no node ids yet: that is phase 2's
|
|
816
|
+
* `page-snapshot` proper (studio plan, #91). Provisional for phase 0 in two
|
|
817
|
+
* ways, both worth knowing before relying on it: it opens a bare page
|
|
818
|
+
* without running the recipe's own steps first, so a start point behind a
|
|
819
|
+
* `session.bootstrap` login shows what an anonymous visitor sees; and it
|
|
820
|
+
* launches a fresh browser or HTTP context per call, one page then closes
|
|
821
|
+
* it, rather than reusing whatever the last sample run had open.
|
|
822
|
+
*
|
|
823
|
+
* @param input - The input recipe; only `mode` and the first `start` point are used.
|
|
824
|
+
* @param browser - Browser launch settings for web mode (an executable path override, headless…).
|
|
825
|
+
* @returns The page's content as plain text.
|
|
826
|
+
* @throws Error when the recipe has no start point.
|
|
827
|
+
*/
|
|
828
|
+
async function fetchStartPage(input, browser) {
|
|
829
|
+
const point = input.start[0];
|
|
830
|
+
if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
|
|
831
|
+
return input.mode === 'web' ? fetchWeb(point.url, browser) : fetchApi(point.url);
|
|
832
|
+
}
|
|
833
|
+
async function fetchWeb(url, browser) {
|
|
834
|
+
const client = await BrowserClient.launch(browser);
|
|
835
|
+
try {
|
|
836
|
+
const session = await client.newSession();
|
|
837
|
+
try {
|
|
838
|
+
await session.page.goto(url);
|
|
839
|
+
return await session.page.content();
|
|
840
|
+
} finally {
|
|
841
|
+
await session.close();
|
|
842
|
+
}
|
|
843
|
+
} finally {
|
|
844
|
+
await client.close();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
async function fetchApi(url) {
|
|
848
|
+
const client = await HttpClient.open({});
|
|
849
|
+
try {
|
|
850
|
+
const response = await client.send({
|
|
851
|
+
url
|
|
852
|
+
});
|
|
853
|
+
return textOf$1(response.body);
|
|
854
|
+
} finally {
|
|
855
|
+
await client.dispose();
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
/** Every body kind the studio can show as text today; others get a placeholder rather than a crash. */
|
|
859
|
+
function textOf$1(body) {
|
|
860
|
+
if (body.kind === 'html') return body.html;
|
|
861
|
+
if (body.kind === 'text') return body.text;
|
|
862
|
+
if (body.kind === 'json') return JSON.stringify(body.data, null, 2);
|
|
863
|
+
return `[${body.kind} document: the content pane shows HTML and JSON responses for now]`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** The attribute `hiddenMarksScript` stamps on an element hidden on the live page. */
|
|
867
|
+
const HIDDEN_ATTRIBUTE = 'data-oc-hidden';
|
|
868
|
+
/**
|
|
869
|
+
* The in-page script `take-snapshot.use-case.ts` runs with `page.evaluate`
|
|
870
|
+
* *before* `page.content()`, so a `display: none`/`visibility: hidden`/
|
|
871
|
+
* zero-size element carries `data-oc-hidden="1"` into the captured markup
|
|
872
|
+
* (studio plan §3.1, issue #91) — the only way to know an element was
|
|
873
|
+
* hidden on the live page, since a static parse of the HTML afterwards has
|
|
874
|
+
* no computed style to ask.
|
|
875
|
+
*
|
|
876
|
+
* Kept as a plain string, not a TypeScript function passed to
|
|
877
|
+
* `page.evaluate`, because it references `window`/`document` as globals: `AGENTS.md`'s
|
|
878
|
+
* "things that bit before" calls out that in-page functions must not do
|
|
879
|
+
* that (the repo's `isolated-functions` lint rule), and every existing
|
|
880
|
+
* `page.evaluate` call with real DOM logic in this codebase (see
|
|
881
|
+
* `packages/core/src/captcha/resolve-captcha.use-case.ts`) already uses
|
|
882
|
+
* this same string-template shape.
|
|
883
|
+
*
|
|
884
|
+
* @param attribute - The attribute to stamp; defaults to `HIDDEN_ATTRIBUTE` (parameterised for tests and for a future caller that wants a different name).
|
|
885
|
+
* @returns A self-invoking script, ready for `page.evaluate(hiddenMarksScript())`.
|
|
886
|
+
*/
|
|
887
|
+
function hiddenMarksScript(attribute = HIDDEN_ATTRIBUTE) {
|
|
888
|
+
return `(() => {
|
|
889
|
+
var attribute = ${JSON.stringify(attribute)}
|
|
890
|
+
var elements = document.querySelectorAll('*')
|
|
891
|
+
for (var i = 0; i < elements.length; i++) {
|
|
892
|
+
var element = elements[i]
|
|
893
|
+
var style = window.getComputedStyle(element)
|
|
894
|
+
var rect = element.getBoundingClientRect()
|
|
895
|
+
var hidden = style.display === 'none' || style.visibility === 'hidden' || (rect.width === 0 && rect.height === 0)
|
|
896
|
+
if (hidden) element.setAttribute(attribute, '1')
|
|
897
|
+
}
|
|
898
|
+
})()`;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Attributes that carry a URL and are rewritten to absolute; `srcset` is handled separately (it carries several). */
|
|
902
|
+
const URL_ATTRIBUTES = ['href', 'src', 'action', 'poster'];
|
|
903
|
+
/** Schemes left untouched: they are not a location `new URL(value, base)` should resolve, or already carry their own meaning. */
|
|
904
|
+
const SKIP_SCHEME = /^(?:javascript|mailto|tel|data|about):/i;
|
|
905
|
+
/**
|
|
906
|
+
* Rewrites captured HTML into what the content pane's sandboxed iframe shows
|
|
907
|
+
* (studio plan §3.1, issue #91): every URL made absolute against `baseUrl`,
|
|
908
|
+
* a `<base>` injected, every `<script>` removed, forms disabled (`onsubmit`
|
|
909
|
+
* stripped, fields made read-only), `<meta http-equiv="refresh">` stripped
|
|
910
|
+
* (it would otherwise navigate the sandboxed frame on its own), and every
|
|
911
|
+
* element given a stable `data-oc-node` id, in document order, for the
|
|
912
|
+
* picker overlay and `selector-inference` to address specific nodes by.
|
|
913
|
+
*
|
|
914
|
+
* Purely a markup transform: it does not know whether an element is hidden
|
|
915
|
+
* on the live page (`display: none`, `visibility: hidden`, zero size) — in
|
|
916
|
+
* web mode that is `hidden-marks.algorithm.ts`'s job, run against the live
|
|
917
|
+
* page *before* this function ever sees the markup, so a `data-oc-hidden`
|
|
918
|
+
* attribute it wrote just survives serialisation untouched here.
|
|
919
|
+
*
|
|
920
|
+
* @param html - The captured document (`page.content()` in web mode, the fetched body in api mode).
|
|
921
|
+
* @param baseUrl - The page's own URL, for resolving relative links and as the `<base>`.
|
|
922
|
+
* @returns The rewritten document and how many elements it stamped.
|
|
923
|
+
*/
|
|
924
|
+
function rewriteDocument(html, baseUrl) {
|
|
925
|
+
const $ = load(html);
|
|
926
|
+
if ($('head').length === 0) $('html').prepend('<head></head>');
|
|
927
|
+
const head = $('head').first();
|
|
928
|
+
head.find('base').remove();
|
|
929
|
+
head.prepend(`<base href="${escapeAttribute(baseUrl)}">`);
|
|
930
|
+
$('meta').each((_index, element) => {
|
|
931
|
+
if (($(element).attr('http-equiv') ?? '').toLowerCase() === 'refresh') $(element).remove();
|
|
932
|
+
});
|
|
933
|
+
$('script').remove();
|
|
934
|
+
$('form').removeAttr('onsubmit');
|
|
935
|
+
$('input, textarea').attr('readonly', 'readonly');
|
|
936
|
+
$('select, button, input[type="submit"], input[type="button"]').attr('disabled', 'disabled');
|
|
937
|
+
$('*').each((_index, element) => {
|
|
938
|
+
const node = $(element);
|
|
939
|
+
for (const attribute of URL_ATTRIBUTES) {
|
|
940
|
+
const value = node.attr(attribute);
|
|
941
|
+
if (value !== undefined) node.attr(attribute, resolveUrl(value, baseUrl) ?? value);
|
|
942
|
+
}
|
|
943
|
+
const srcset = node.attr('srcset');
|
|
944
|
+
if (srcset !== undefined) node.attr('srcset', rewriteSrcset(srcset, baseUrl));
|
|
945
|
+
});
|
|
946
|
+
let nodeCount = 0;
|
|
947
|
+
$('*').each((_index, element) => {
|
|
948
|
+
$(element).attr(NODE_ID_ATTRIBUTE, `n${nodeCount}`);
|
|
949
|
+
nodeCount += 1;
|
|
950
|
+
});
|
|
951
|
+
return {
|
|
952
|
+
html: $.html(),
|
|
953
|
+
nodeCount
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
/** Resolves one URL against `baseUrl`; a scheme that is not a location (`javascript:`, `data:`…), or a value `new URL` cannot parse, is left as it was. */
|
|
957
|
+
function resolveUrl(value, baseUrl) {
|
|
958
|
+
const trimmed = value.trim();
|
|
959
|
+
if (trimmed === '' || SKIP_SCHEME.test(trimmed)) return undefined;
|
|
960
|
+
try {
|
|
961
|
+
return new URL(trimmed, baseUrl).href;
|
|
962
|
+
} catch {
|
|
963
|
+
return undefined;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
/** `srcset`'s own micro-syntax: comma-separated `url descriptor?` pairs. Malformed entries are passed through unresolved rather than dropped. */
|
|
967
|
+
function rewriteSrcset(value, baseUrl) {
|
|
968
|
+
return value.split(',').map(candidate => {
|
|
969
|
+
const trimmed = candidate.trim();
|
|
970
|
+
const spaceAt = trimmed.indexOf(' ');
|
|
971
|
+
const url = spaceAt === -1 ? trimmed : trimmed.slice(0, spaceAt);
|
|
972
|
+
const descriptor = spaceAt === -1 ? '' : trimmed.slice(spaceAt);
|
|
973
|
+
return `${resolveUrl(url, baseUrl) ?? url}${descriptor}`;
|
|
974
|
+
}).join(', ');
|
|
975
|
+
}
|
|
976
|
+
function escapeAttribute(value) {
|
|
977
|
+
return value.replaceAll('&', '&').replaceAll('"', '"');
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Captures the snapshot the content pane shows (studio plan §3.1, issue
|
|
982
|
+
* #91): runs the recipe up to `stepPath`, serialises the document, marks
|
|
983
|
+
* hidden elements in web mode, and hands it to `rewrite-document.mapper.ts`
|
|
984
|
+
* for display.
|
|
985
|
+
*
|
|
986
|
+
* `stepPath` is accepted (and cached against, by `snapshot.store.ts`) for
|
|
987
|
+
* the API the UI needs — one snapshot per selected step — but v1 only
|
|
988
|
+
* actually *captures* `"start"`: the first `start` point, before any step
|
|
989
|
+
* of the recipe has run, the same reach `fetch-start-page.use-case.ts` (phase
|
|
990
|
+
* 0) already had. Snapshotting mid-recipe (after an arbitrary later step)
|
|
991
|
+
* needs a "stop the crawl after step X and hand back the live page/document"
|
|
992
|
+
* hook in `crawl-execution` that does not exist yet; building one was out of
|
|
993
|
+
* this phase's budget (see this phase's final report for the tradeoff this
|
|
994
|
+
* was weighed against). Any other `stepPath` throws, clearly, rather than
|
|
995
|
+
* silently returning the start page under a misleading label.
|
|
996
|
+
*
|
|
997
|
+
* @param input - The input recipe; only `mode` and the first `start` point are used.
|
|
998
|
+
* @param stepPath - The step to snapshot after, e.g. `"start"` or `"steps.2"` (`scope-outline`'s path format).
|
|
999
|
+
* @param browser - Browser launch settings for web mode.
|
|
1000
|
+
* @returns The rewritten snapshot.
|
|
1001
|
+
* @throws Error when the recipe has no start point, or `stepPath` is not `"start"`.
|
|
1002
|
+
*/
|
|
1003
|
+
async function takeSnapshot(input, stepPath, browser) {
|
|
1004
|
+
if (stepPath !== 'start') throw new Error(`takeSnapshot: "${stepPath}" is not supported yet — only "start" (the first start point, before any step runs) is; see this function's doc comment`);
|
|
1005
|
+
const point = input.start[0];
|
|
1006
|
+
if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
|
|
1007
|
+
return input.mode === 'web' ? snapshotWeb(point.url, browser) : snapshotApi(point.url);
|
|
1008
|
+
}
|
|
1009
|
+
async function snapshotWeb(url, browser) {
|
|
1010
|
+
const client = await BrowserClient.launch(browser);
|
|
1011
|
+
try {
|
|
1012
|
+
const session = await client.newSession();
|
|
1013
|
+
try {
|
|
1014
|
+
await session.page.goto(url);
|
|
1015
|
+
await session.page.evaluate(hiddenMarksScript(HIDDEN_ATTRIBUTE));
|
|
1016
|
+
const html = await session.page.content();
|
|
1017
|
+
const baseUrl = session.page.url();
|
|
1018
|
+
const rewritten = rewriteDocument(html, baseUrl);
|
|
1019
|
+
return {
|
|
1020
|
+
...rewritten,
|
|
1021
|
+
baseUrl
|
|
1022
|
+
};
|
|
1023
|
+
} finally {
|
|
1024
|
+
await session.close();
|
|
1025
|
+
}
|
|
1026
|
+
} finally {
|
|
1027
|
+
await client.close();
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
async function snapshotApi(url) {
|
|
1031
|
+
const client = await HttpClient.open({});
|
|
1032
|
+
try {
|
|
1033
|
+
const response = await client.send({
|
|
1034
|
+
url
|
|
1035
|
+
});
|
|
1036
|
+
const rewritten = rewriteDocument(textOf(response.body), url);
|
|
1037
|
+
return {
|
|
1038
|
+
...rewritten,
|
|
1039
|
+
baseUrl: url
|
|
1040
|
+
};
|
|
1041
|
+
} finally {
|
|
1042
|
+
await client.dispose();
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
/** Every body kind the studio can show as text today; others get a placeholder rather than a crash (mirrors `fetch-start-page.use-case.ts`'s own `textOf`). */
|
|
1046
|
+
function textOf(body) {
|
|
1047
|
+
if (body.kind === 'html') return body.html;
|
|
1048
|
+
if (body.kind === 'text') return body.text;
|
|
1049
|
+
if (body.kind === 'json') return `<pre>${JSON.stringify(body.data, null, 2)}</pre>`;
|
|
1050
|
+
return `<p>[${body.kind} document: the content pane shows HTML and JSON responses for now]</p>`;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** @returns An empty cache, for a freshly opened workspace. */
|
|
1054
|
+
function createSnapshotCache() {
|
|
1055
|
+
return {
|
|
1056
|
+
entries: new Map()
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
/** @returns The cached snapshot for `recipeId`/`path`, or `undefined` when none was taken yet. */
|
|
1060
|
+
function cachedSnapshot(cache, recipeId, path) {
|
|
1061
|
+
return cache.entries.get(keyOf(recipeId, path));
|
|
1062
|
+
}
|
|
1063
|
+
/** Records a freshly taken snapshot, replacing whatever was cached for the same recipe and step path. */
|
|
1064
|
+
function putSnapshot(cache, recipeId, path, snapshot) {
|
|
1065
|
+
cache.entries.set(keyOf(recipeId, path), snapshot);
|
|
1066
|
+
}
|
|
1067
|
+
function keyOf(recipeId, path) {
|
|
1068
|
+
return `${recipeId}:${path}`;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
353
1071
|
/**
|
|
354
1072
|
* Handles `fetch-start-page`: loads the named input recipe from the current
|
|
355
1073
|
* workspace and fetches its start point (see `page-snapshot`'s own caveats:
|
|
@@ -365,12 +1083,504 @@ async function handleFetchStartPage(state, command) {
|
|
|
365
1083
|
const {
|
|
366
1084
|
input
|
|
367
1085
|
} = await loadRecipePair(state.folder, command.recipeId);
|
|
368
|
-
const html = await fetchStartPage(input);
|
|
1086
|
+
const html = await fetchStartPage(input, state.browser);
|
|
369
1087
|
return {
|
|
370
1088
|
html
|
|
371
1089
|
};
|
|
372
1090
|
}
|
|
373
1091
|
|
|
1092
|
+
const GENERATED_ID = /^(?:react-|radix-|:r|ember|css-|mui-|headlessui-)/i;
|
|
1093
|
+
/**
|
|
1094
|
+
* The path from the document root to `node`, one level per ancestor. Two
|
|
1095
|
+
* elements that share an ancestor produce identical levels up to that
|
|
1096
|
+
* ancestor and diverge exactly where their parents stop being the same node
|
|
1097
|
+
* — `list-inference.algorithm.ts` leans on that to find the nearest pair of
|
|
1098
|
+
* siblings two picks belong to, without ever touching cheerio again.
|
|
1099
|
+
*
|
|
1100
|
+
* @param $ - The parsed document.
|
|
1101
|
+
* @param node - The element to describe.
|
|
1102
|
+
* @returns The path, root first.
|
|
1103
|
+
*/
|
|
1104
|
+
function domPathOf($, node) {
|
|
1105
|
+
const chain = [];
|
|
1106
|
+
let current = node;
|
|
1107
|
+
while (current !== undefined && current.length > 0 && isElement(current.get(0))) {
|
|
1108
|
+
chain.unshift(current);
|
|
1109
|
+
const parent = current.parent();
|
|
1110
|
+
current = parent.length > 0 ? parent : undefined;
|
|
1111
|
+
}
|
|
1112
|
+
return chain.map(element => levelOf($, element));
|
|
1113
|
+
}
|
|
1114
|
+
function levelOf($, element) {
|
|
1115
|
+
const node = element.get(0);
|
|
1116
|
+
const tag = node.tagName.toLowerCase();
|
|
1117
|
+
const attrs = {};
|
|
1118
|
+
const entries = Object.entries(node.attribs ?? {});
|
|
1119
|
+
for (const [name, value] of entries) attrs[name] = value;
|
|
1120
|
+
const classes = (attrs.class ?? '').split(/\s+/).filter(token => token.length > 0);
|
|
1121
|
+
const siblings = element.parent().length > 0 ? element.parent().children(tag) : $.root().children(tag);
|
|
1122
|
+
const index = siblings.toArray().indexOf(node) + 1;
|
|
1123
|
+
return {
|
|
1124
|
+
tag,
|
|
1125
|
+
id: attrs.id,
|
|
1126
|
+
classes,
|
|
1127
|
+
attrs,
|
|
1128
|
+
index: Math.max(index, 1),
|
|
1129
|
+
siblingCount: Math.max(siblings.length, 1)
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
function isElement(node) {
|
|
1133
|
+
return node !== undefined && node.type === 'tag';
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Whether `id` looks handed by a framework (`react-`, a CSS-module or a
|
|
1137
|
+
* `radix-`/`mui-` autogenerated one) rather than by the site's own markup —
|
|
1138
|
+
* `class-token.policy.ts` has the equivalent check for class tokens, kept
|
|
1139
|
+
* separate because ids and classes are ranked at different tiers.
|
|
1140
|
+
*/
|
|
1141
|
+
function isStableId(id) {
|
|
1142
|
+
return id.length > 0 && !GENERATED_ID.test(id);
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Whether `path` failed to reach the real document root. `domPathOf` walks
|
|
1146
|
+
* up through cheerio's own `.parent()`, which (matching real DOM semantics)
|
|
1147
|
+
* never crosses from a `<template>`'s content into the light DOM outside
|
|
1148
|
+
* it — a `<template shadowrootmode>` is exactly declarative shadow DOM, the
|
|
1149
|
+
* only shadow-DOM shape that can show up in static, server-captured markup
|
|
1150
|
+
* at all (imperative `attachShadow()` content never serialises into
|
|
1151
|
+
* `page.content()`/a fetched response in the first place, so that case
|
|
1152
|
+
* cannot reach this slice and needs no separate detection). A path whose
|
|
1153
|
+
* first level is not `html` was cut short at exactly this kind of boundary:
|
|
1154
|
+
* v1 does not pierce it, so callers report a clear "shadow DOM is not
|
|
1155
|
+
* supported" error instead of building ancestor-based candidates over a
|
|
1156
|
+
* chain that stops partway up.
|
|
1157
|
+
*/
|
|
1158
|
+
function crossesShadowRoot(path) {
|
|
1159
|
+
return path.length === 0 || path[0].tag !== 'html';
|
|
1160
|
+
}
|
|
1161
|
+
/** Parses `html` and returns cheerio's API, for callers that need more than one path from the same document. */
|
|
1162
|
+
function parseDocument$1(html) {
|
|
1163
|
+
return load(html);
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* The path to the element the snapshot stamped with `nodeId` (its
|
|
1167
|
+
* `data-oc-node` attribute — `@opencraw/core`'s `NODE_ID_ATTRIBUTE`,
|
|
1168
|
+
* `packages/studio/src/page-snapshot/rewrite-document.mapper.ts`). This is
|
|
1169
|
+
* how a click in the content pane's iframe — which only ever needs to read
|
|
1170
|
+
* that one attribute off the element the user clicked — turns into
|
|
1171
|
+
* something `selector-inference`'s other algorithms can work with, without
|
|
1172
|
+
* the browser-side picker script needing to know anything about ancestry,
|
|
1173
|
+
* classes or candidate ranking itself.
|
|
1174
|
+
*
|
|
1175
|
+
* @param html - The snapshot document (already stamped with node ids).
|
|
1176
|
+
* @param nodeId - The `data-oc-node` value to find.
|
|
1177
|
+
* @returns The node's path, or `undefined` when no element carries that id.
|
|
1178
|
+
*/
|
|
1179
|
+
function pathToNode(html, nodeId) {
|
|
1180
|
+
const $ = parseDocument$1(html);
|
|
1181
|
+
const found = $(`[${NODE_ID_ATTRIBUTE}="${cssAttributeValue(nodeId)}"]`);
|
|
1182
|
+
return found.length === 0 ? undefined : domPathOf($, found.first());
|
|
1183
|
+
}
|
|
1184
|
+
function cssAttributeValue(value) {
|
|
1185
|
+
return value.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** Attributes worth reading instead of text, in preference order, when the clicked element has one. */
|
|
1189
|
+
const PREFERRED_ATTRS = {
|
|
1190
|
+
a: ['href'],
|
|
1191
|
+
img: ['src', 'alt']
|
|
1192
|
+
};
|
|
1193
|
+
/**
|
|
1194
|
+
* What a `Read` card's `take` should be for the element a pick landed on
|
|
1195
|
+
* (issue #91, studio plan §3.2): `attr:href` for an `<a>`, `attr:src` for an
|
|
1196
|
+
* `<img>`, `attr:<name>` for the first `data-*` attribute otherwise, `json`
|
|
1197
|
+
* for a `<script type="application/json">`, else the default, `text`.
|
|
1198
|
+
*
|
|
1199
|
+
* @param level - The clicked element's own level (the last entry of its `DomPath`).
|
|
1200
|
+
* @returns The `take` value to put on the `extract` step.
|
|
1201
|
+
*/
|
|
1202
|
+
function takeKindFor(level) {
|
|
1203
|
+
if (level.tag === 'script' && (level.attrs.type ?? '').toLowerCase() === 'application/json') return 'json';
|
|
1204
|
+
const preferred = PREFERRED_ATTRS[level.tag];
|
|
1205
|
+
if (preferred !== undefined) {
|
|
1206
|
+
const attribute = preferred.find(name => level.attrs[name] !== undefined);
|
|
1207
|
+
if (attribute !== undefined) return `attr:${attribute}`;
|
|
1208
|
+
}
|
|
1209
|
+
const dataAttribute = Object.keys(level.attrs).find(name => name.startsWith('data-') && name !== 'data-oc-node' && name !== 'data-oc-hidden');
|
|
1210
|
+
if (dataAttribute !== undefined) return `attr:${dataAttribute}`;
|
|
1211
|
+
return 'text';
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Which class tokens are worth building a selector on. A hand-authored class
|
|
1216
|
+
* (`product_pod`, `price_color`) survives a rebuild; a generated one
|
|
1217
|
+
* (a content hash, a CSS-module suffix, a styled-components token) does not,
|
|
1218
|
+
* so a candidate built on it looks fine on the snapshot it was picked from
|
|
1219
|
+
* and breaks on the very next deploy. This is the "generated/hashed class
|
|
1220
|
+
* names" tradeoff issue #91 calls out explicitly: the heuristics below are a
|
|
1221
|
+
* reasoned judgement call, not a guarantee — see the doc comments on each
|
|
1222
|
+
* pattern for what real-world tool it targets.
|
|
1223
|
+
*/
|
|
1224
|
+
const GENERATED_TOKEN_PATTERNS = [/** styled-components / emotion: `sc-bdVaJa`, `css-1x2y3z4` */
|
|
1225
|
+
/^(?:sc|css)-[a-z0-9]+$/i, /** CSS Modules, most bundlers' default hash suffix: `wrapper__3fA9k`, `Button-module_root__aB3dK` */
|
|
1226
|
+
/__[a-z0-9]{4,10}$/i, /** JSS / MUI: `jss123`, `MuiButton-root-145` */
|
|
1227
|
+
/^(?:jss|mui[a-z]*-)[a-z0-9-]*\d{2}$/i, /** A token that is only digits: not a class a human would type. */
|
|
1228
|
+
/^\d+$/, /** A bare hex hash: `a3f9c21`, `1a2b3c4d` (6+ hex chars, nothing else). */
|
|
1229
|
+
/^[0-9a-f]{6,}$/i, /** Tailwind/atomic-CSS-in-JS runtime classes some bundlers emit: `_1a2b3c` */
|
|
1230
|
+
/^_[0-9a-z]{5,}$/i];
|
|
1231
|
+
/**
|
|
1232
|
+
* Whether a class token looks hand-authored and safe to build a selector on.
|
|
1233
|
+
*
|
|
1234
|
+
* @param token - One class, no surrounding whitespace.
|
|
1235
|
+
* @returns `false` for anything matching a known generated-class shape.
|
|
1236
|
+
*/
|
|
1237
|
+
function isStableClassToken(token) {
|
|
1238
|
+
return token.length > 0 && GENERATED_TOKEN_PATTERNS.every(pattern => !pattern.test(token));
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* The subset of `tokens` worth ranking a selector candidate on, order kept.
|
|
1242
|
+
*
|
|
1243
|
+
* @param tokens - An element's class list.
|
|
1244
|
+
* @returns Only the stable tokens.
|
|
1245
|
+
*/
|
|
1246
|
+
function stableClasses(tokens) {
|
|
1247
|
+
return tokens.filter(token => isStableClassToken(token));
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Whether two class lists describe "the same kind of element" for list
|
|
1251
|
+
* inference: same stable classes, generated tokens (which often differ
|
|
1252
|
+
* per-instance, e.g. a React `key`-derived class) ignored on both sides.
|
|
1253
|
+
*
|
|
1254
|
+
* @param a - One element's classes.
|
|
1255
|
+
* @param b - Another element's classes.
|
|
1256
|
+
* @returns Whether their stable class sets are equal, order ignored.
|
|
1257
|
+
*/
|
|
1258
|
+
function sameClassSignature(a, b) {
|
|
1259
|
+
const left = new Set(stableClasses(a));
|
|
1260
|
+
const right = new Set(stableClasses(b));
|
|
1261
|
+
if (left.size !== right.size) return false;
|
|
1262
|
+
return [...left].every(token => right.has(token));
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Thrown when a pick or a list-inference walk crosses a declarative shadow
|
|
1267
|
+
* root (`<template shadowrootmode>`). v1 does not pierce shadow DOM: rather
|
|
1268
|
+
* than infer a selector that silently matches the wrong thing (or nothing,
|
|
1269
|
+
* once the page hydrates), the studio surfaces this and stops.
|
|
1270
|
+
*/
|
|
1271
|
+
class ShadowDomUnsupportedError extends Error {
|
|
1272
|
+
path;
|
|
1273
|
+
constructor(path) {
|
|
1274
|
+
super(`"${path}" is inside a shadow root: shadow DOM is not supported for picking (v1)`);
|
|
1275
|
+
this.path = path;
|
|
1276
|
+
this.name = 'ShadowDomUnsupportedError';
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/** Attributes ranked just under `id`: named explicitly, not "any `data-*`", because a generated `data-reactid` is no more stable than a generated class. */
|
|
1281
|
+
const STABLE_ATTRS = ['data-testid', 'data-id', 'itemprop'];
|
|
1282
|
+
const TIER_ORDER$1 = {
|
|
1283
|
+
'id': 0,
|
|
1284
|
+
'data-attr': 1,
|
|
1285
|
+
'class': 2,
|
|
1286
|
+
'structure': 3,
|
|
1287
|
+
'position': 4
|
|
1288
|
+
};
|
|
1289
|
+
/**
|
|
1290
|
+
* Every selector candidate worth trying for the last node of `path`, ranked
|
|
1291
|
+
* best-first: `id` > stable `data-*`/`itemprop` attributes > stable class
|
|
1292
|
+
* tokens > ancestor structure (`parent > tag`) > position (`nth-of-type`) as
|
|
1293
|
+
* a last resort (issue #91's ranking, studio plan §3.2). Structure and
|
|
1294
|
+
* position candidates only look as far up as `path` itself goes — pass a
|
|
1295
|
+
* sub-path (e.g. from an item's root down) to keep a field's selector
|
|
1296
|
+
* relative to its item instead of absolute to the document.
|
|
1297
|
+
*
|
|
1298
|
+
* @param path - The node's ancestry, root (or scope root) first, itself last.
|
|
1299
|
+
* @param includePosition - `false` for an item selector: a position candidate would only ever match one of the items, which is never what an item selector should do.
|
|
1300
|
+
* @returns Candidates, best tier first; ties keep this function's own order.
|
|
1301
|
+
*/
|
|
1302
|
+
function candidatesFor(path, includePosition = true) {
|
|
1303
|
+
const node = path.at(-1);
|
|
1304
|
+
if (node === undefined) return [];
|
|
1305
|
+
const candidates = [];
|
|
1306
|
+
if (node.id !== undefined && isStableId(node.id)) candidates.push({
|
|
1307
|
+
selector: `#${cssEscape(node.id)}`,
|
|
1308
|
+
tier: 'id'
|
|
1309
|
+
});
|
|
1310
|
+
for (const name of STABLE_ATTRS) {
|
|
1311
|
+
const value = node.attrs[name];
|
|
1312
|
+
if (value !== undefined && value.length > 0) candidates.push({
|
|
1313
|
+
selector: `${node.tag}[${name}="${cssValue(value)}"]`,
|
|
1314
|
+
tier: 'data-attr'
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
const classes = stableClasses(node.classes);
|
|
1318
|
+
if (classes.length > 0) candidates.push({
|
|
1319
|
+
selector: `${node.tag}.${classes.map(token => cssEscape(token)).join('.')}`,
|
|
1320
|
+
tier: 'class'
|
|
1321
|
+
});
|
|
1322
|
+
candidates.push(...structureCandidates(path));
|
|
1323
|
+
if (includePosition) candidates.push(...positionCandidates(path));
|
|
1324
|
+
return sortByTier(candidates);
|
|
1325
|
+
}
|
|
1326
|
+
/** `ancestor > ... > tag`, growing from 2 to 4 levels (or to the top of `path`), each ancestor named by its own best simple form. */
|
|
1327
|
+
function structureCandidates(path) {
|
|
1328
|
+
const node = path.at(-1);
|
|
1329
|
+
if (node === undefined) return [];
|
|
1330
|
+
const candidates = [];
|
|
1331
|
+
const maxDepth = Math.min(path.length - 1, 3);
|
|
1332
|
+
for (let depth = 1; depth <= maxDepth; depth++) {
|
|
1333
|
+
const ancestor = path.at(-1 - depth);
|
|
1334
|
+
if (ancestor === undefined) break;
|
|
1335
|
+
const between = Array.from({
|
|
1336
|
+
length: depth - 1
|
|
1337
|
+
}, (_, index) => path.at(-depth + index)?.tag ?? '');
|
|
1338
|
+
const parts = [simpleSelector(ancestor), ...between, node.tag];
|
|
1339
|
+
candidates.push({
|
|
1340
|
+
selector: parts.join(' > '),
|
|
1341
|
+
tier: 'structure'
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
return candidates;
|
|
1345
|
+
}
|
|
1346
|
+
/** `parent > tag:nth-of-type(n)`, the position-only fallback; never `nth-child`, which counts every sibling tag, not just this element's own. */
|
|
1347
|
+
function positionCandidates(path) {
|
|
1348
|
+
const node = path.at(-1);
|
|
1349
|
+
if (node === undefined) return [];
|
|
1350
|
+
const own = node.siblingCount > 1 ? `${node.tag}:nth-of-type(${node.index})` : node.tag;
|
|
1351
|
+
const parent = path.at(-2);
|
|
1352
|
+
if (parent === undefined) return [{
|
|
1353
|
+
selector: own,
|
|
1354
|
+
tier: 'position'
|
|
1355
|
+
}];
|
|
1356
|
+
return [{
|
|
1357
|
+
selector: `${simpleSelector(parent)} > ${own}`,
|
|
1358
|
+
tier: 'position'
|
|
1359
|
+
}];
|
|
1360
|
+
}
|
|
1361
|
+
/** A level's own selector fragment, no combinators: id, else stable classes, else the bare tag. */
|
|
1362
|
+
function simpleSelector(level) {
|
|
1363
|
+
if (level.id !== undefined && isStableId(level.id)) return `#${cssEscape(level.id)}`;
|
|
1364
|
+
const classes = stableClasses(level.classes);
|
|
1365
|
+
return classes.length > 0 ? `${level.tag}.${classes.map(token => cssEscape(token)).join('.')}` : level.tag;
|
|
1366
|
+
}
|
|
1367
|
+
function sortByTier(candidates) {
|
|
1368
|
+
return [...candidates].sort((a, b) => TIER_ORDER$1[a.tier] - TIER_ORDER$1[b.tier]);
|
|
1369
|
+
}
|
|
1370
|
+
/** Escapes a CSS identifier segment (an id or a class token) well enough for the shapes real sites use; not a full CSS.escape polyfill. */
|
|
1371
|
+
function cssEscape(token) {
|
|
1372
|
+
return token.replaceAll(/([^\w-])/gi, String.raw`\$1`);
|
|
1373
|
+
}
|
|
1374
|
+
/** Escapes a value inside `[name="…"]`. */
|
|
1375
|
+
function cssValue(value) {
|
|
1376
|
+
return value.replaceAll(/(["\\])/g, String.raw`\$1`);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const TIER_ORDER = {
|
|
1380
|
+
'id': 0,
|
|
1381
|
+
'data-attr': 1,
|
|
1382
|
+
'class': 2,
|
|
1383
|
+
'structure': 3,
|
|
1384
|
+
'position': 4
|
|
1385
|
+
};
|
|
1386
|
+
/**
|
|
1387
|
+
* Verifies and orders selector candidates against real markup, by running
|
|
1388
|
+
* each one through the engine's own CSS matching (`@opencraw/core`'s
|
|
1389
|
+
* `countMatches`/`matchesOf`) rather than reimplementing it — issue #91's
|
|
1390
|
+
* "every candidate is verified" requirement.
|
|
1391
|
+
*
|
|
1392
|
+
* Ordering: candidates that do not match `targetNodeId` (when given) sort
|
|
1393
|
+
* last; among the rest, best tier first, and within a tier the fewest
|
|
1394
|
+
* matches first (the most specific selector that still hits the target).
|
|
1395
|
+
* A candidate matching nothing is dropped entirely: it is not a real option.
|
|
1396
|
+
*
|
|
1397
|
+
* @param html - The document to verify against (the snapshot, or the live page for the second, cross-check pass).
|
|
1398
|
+
* @param candidates - Unranked candidates, e.g. from `candidatesFor`.
|
|
1399
|
+
* @param targetNodeId - The `data-oc-node` id the pick landed on, when the document carries those stamps (the snapshot does; a live page fetched separately does not).
|
|
1400
|
+
* @returns Every candidate that matched at least one element, ranked best first.
|
|
1401
|
+
*/
|
|
1402
|
+
function rankCandidates(html, candidates, targetNodeId) {
|
|
1403
|
+
const ranked = candidates.map(candidate => {
|
|
1404
|
+
const matches = countMatches(html, candidate.selector);
|
|
1405
|
+
const hitsTarget = targetNodeId === undefined ;
|
|
1406
|
+
return {
|
|
1407
|
+
...candidate,
|
|
1408
|
+
matches,
|
|
1409
|
+
hitsTarget
|
|
1410
|
+
};
|
|
1411
|
+
});
|
|
1412
|
+
return ranked.filter(candidate => candidate.matches > 0).sort((a, b) => Number(b.hitsTarget) - Number(a.hitsTarget) || TIER_ORDER[a.tier] - TIER_ORDER[b.tier] || a.matches - b.matches);
|
|
1413
|
+
}
|
|
1414
|
+
/** The single best candidate, or `undefined` when none of them matched anything. */
|
|
1415
|
+
function bestCandidate(html, candidates, targetNodeId) {
|
|
1416
|
+
return rankCandidates(html, candidates, targetNodeId)[0];
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* Infers "these two picks are the same field on two items of a list" from
|
|
1421
|
+
* two node paths, by walking up from both to the nearest ancestors that are
|
|
1422
|
+
* real siblings (the same parent) with the same tag and stable-class
|
|
1423
|
+
* signature — studio plan §3.2, issue #91. That ancestor is the item; the
|
|
1424
|
+
* remainder of each path is the field, relative to it.
|
|
1425
|
+
*
|
|
1426
|
+
* Because a `DomPath` is built root-down (`dom-path.model.ts`), two paths
|
|
1427
|
+
* from the same document are byte-for-byte identical up to their real
|
|
1428
|
+
* common ancestor and diverge exactly at its differing children — so the
|
|
1429
|
+
* *first* level where they differ is always the nearest-sibling test point;
|
|
1430
|
+
* there is no need to look further down. This also makes the "wrapper trap"
|
|
1431
|
+
* (an extra single-child element around each item) resolve itself: a
|
|
1432
|
+
* wrapper's own instances are never one another's siblings, only the
|
|
1433
|
+
* genuine repeating element is, so the walk naturally lands there instead of
|
|
1434
|
+
* on the wrapper (see the wrapper-trap fixture in this slice's tests).
|
|
1435
|
+
*
|
|
1436
|
+
* @param pathA - The first click's path.
|
|
1437
|
+
* @param pathB - The second click's path.
|
|
1438
|
+
* @returns The item and field shapes (generalised) plus each pick's raw path, or `null` when the two picks are not on a coherent list (different branches entirely, the same node twice, or one path nested inside the other).
|
|
1439
|
+
* @throws ShadowDomUnsupportedError when either path crosses a declarative shadow root.
|
|
1440
|
+
*/
|
|
1441
|
+
function inferList(pathA, pathB) {
|
|
1442
|
+
if (crossesShadowRoot(pathA)) throw new ShadowDomUnsupportedError(pathToString(pathA));
|
|
1443
|
+
if (crossesShadowRoot(pathB)) throw new ShadowDomUnsupportedError(pathToString(pathB));
|
|
1444
|
+
let divergedAt = -1;
|
|
1445
|
+
const limit = Math.min(pathA.length, pathB.length);
|
|
1446
|
+
for (let index = 0; index < limit; index++) {
|
|
1447
|
+
if (!sameLevel(pathA[index], pathB[index])) {
|
|
1448
|
+
divergedAt = index;
|
|
1449
|
+
break;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
// Identical paths, or one a strict prefix of the other (the same node picked twice, or one element nested inside the other): not a list.
|
|
1453
|
+
if (divergedAt <= 0) return null;
|
|
1454
|
+
const branchA = pathA[divergedAt];
|
|
1455
|
+
const branchB = pathB[divergedAt];
|
|
1456
|
+
if (branchA.tag !== branchB.tag || !sameClassSignature(branchA.classes, branchB.classes)) return null;
|
|
1457
|
+
if (branchA.index === branchB.index) return null; // Same position: not two different items.
|
|
1458
|
+
const ancestors = pathA.slice(0, divergedAt);
|
|
1459
|
+
const fieldPathA = pathA.slice(divergedAt);
|
|
1460
|
+
const fieldPathB = pathB.slice(divergedAt);
|
|
1461
|
+
const fieldPath = mergeRelativePaths(fieldPathA, fieldPathB);
|
|
1462
|
+
return {
|
|
1463
|
+
itemPath: [...ancestors, fieldPath[0]],
|
|
1464
|
+
fieldPath,
|
|
1465
|
+
fieldPathA,
|
|
1466
|
+
fieldPathB
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
function sameLevel(a, b) {
|
|
1470
|
+
return a.tag === b.tag && a.id === b.id && a.index === b.index && a.siblingCount === b.siblingCount && sameClassSignature(a.classes, b.classes);
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Zips two item-relative paths (item first) level by level into one that
|
|
1474
|
+
* only keeps what both share: the tag (must already match, checked by the
|
|
1475
|
+
* caller for level 0), the intersection of stable classes, and attributes
|
|
1476
|
+
* present with the same value on both sides. Shorter than the other when
|
|
1477
|
+
* the two picks' local structure differs in depth (a real possibility once
|
|
1478
|
+
* a field is missing on one item; `list-inference.algorithm.test.ts`'s
|
|
1479
|
+
* shifted-field fixture is exactly this) — the merge just stops there,
|
|
1480
|
+
* which is fine: a field selector only needs to reach as deep as the
|
|
1481
|
+
* shallower of the two picks.
|
|
1482
|
+
*/
|
|
1483
|
+
function mergeRelativePaths(a, b) {
|
|
1484
|
+
const length = Math.min(a.length, b.length);
|
|
1485
|
+
const merged = [];
|
|
1486
|
+
for (let index = 0; index < length; index++) {
|
|
1487
|
+
const left = a[index];
|
|
1488
|
+
const right = b[index];
|
|
1489
|
+
if (left.tag !== right.tag) break;
|
|
1490
|
+
merged.push(mergeLevel(left, right));
|
|
1491
|
+
}
|
|
1492
|
+
return merged.length > 0 ? merged : [a[0]];
|
|
1493
|
+
}
|
|
1494
|
+
function mergeLevel(a, b) {
|
|
1495
|
+
const classes = stableClasses(a.classes).filter(token => b.classes.includes(token));
|
|
1496
|
+
const attrs = {};
|
|
1497
|
+
for (const [name, value] of Object.entries(a.attrs)) {
|
|
1498
|
+
if (name !== 'class' && name !== 'id' && b.attrs[name] === value) attrs[name] = value;
|
|
1499
|
+
}
|
|
1500
|
+
return {
|
|
1501
|
+
tag: a.tag,
|
|
1502
|
+
id: a.id !== undefined && a.id === b.id ? a.id : undefined,
|
|
1503
|
+
classes,
|
|
1504
|
+
attrs,
|
|
1505
|
+
index: 1,
|
|
1506
|
+
siblingCount: Math.max(a.siblingCount, b.siblingCount, 1)
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
function pathToString(path) {
|
|
1510
|
+
return path.map(level => level.tag + (level.id === undefined ? '' : `#${level.id}`)).join(' > ');
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Handles `infer-selector`: turns one or two picked nodes (their
|
|
1515
|
+
* `data-oc-node` ids, off the cached snapshot) into a verified selector —
|
|
1516
|
+
* one id for a `Read` card's field, two for the safe item+field list shape
|
|
1517
|
+
* `selector-inference`'s `inferList` builds by construction (issue #91,
|
|
1518
|
+
* studio plan §3.2). All the actual candidate/ranking/list-inference work
|
|
1519
|
+
* is `selector-inference`'s (pure); this handler only locates the clicked
|
|
1520
|
+
* nodes in the cached document and calls into it.
|
|
1521
|
+
*
|
|
1522
|
+
* @param state - The server state.
|
|
1523
|
+
* @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
|
|
1524
|
+
* @returns A field pick, a list pick, or `"unsupported"` with why.
|
|
1525
|
+
* @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
|
|
1526
|
+
*/
|
|
1527
|
+
function handleInferSelector(state, command) {
|
|
1528
|
+
const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
|
|
1529
|
+
if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
|
|
1530
|
+
const html = snapshot.html;
|
|
1531
|
+
const paths = command.nodeIds.map(nodeId => pathToNode(html, nodeId));
|
|
1532
|
+
if (paths.includes(undefined)) return {
|
|
1533
|
+
kind: 'unsupported',
|
|
1534
|
+
reason: 'one of the picked nodes is no longer in the snapshot; take a fresh snapshot and try again'
|
|
1535
|
+
};
|
|
1536
|
+
const [pathA, pathB] = paths;
|
|
1537
|
+
if (pathB === undefined) return {
|
|
1538
|
+
kind: 'field',
|
|
1539
|
+
field: fieldPick(html, pathA)
|
|
1540
|
+
};
|
|
1541
|
+
try {
|
|
1542
|
+
const result = inferList(pathA, pathB);
|
|
1543
|
+
if (result === null) return {
|
|
1544
|
+
kind: 'unsupported',
|
|
1545
|
+
reason: 'these two picks do not belong to the same list: pick the same field on two similar items'
|
|
1546
|
+
};
|
|
1547
|
+
const item = bestCandidate(html, candidatesFor(result.itemPath, false));
|
|
1548
|
+
if (item === undefined) return {
|
|
1549
|
+
kind: 'unsupported',
|
|
1550
|
+
reason: 'no selector matches every item of this list; try picking two items closer to each other in the markup'
|
|
1551
|
+
};
|
|
1552
|
+
const field = fieldPick(html, result.fieldPathA, result.fieldPath);
|
|
1553
|
+
return {
|
|
1554
|
+
kind: 'list',
|
|
1555
|
+
item: {
|
|
1556
|
+
selector: item.selector,
|
|
1557
|
+
tier: item.tier,
|
|
1558
|
+
matches: item.matches
|
|
1559
|
+
},
|
|
1560
|
+
field
|
|
1561
|
+
};
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
if (error instanceof ShadowDomUnsupportedError) return {
|
|
1564
|
+
kind: 'unsupported',
|
|
1565
|
+
reason: error.message
|
|
1566
|
+
};
|
|
1567
|
+
throw error;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
/** The best selector for one clicked node, `take` decided from the concrete clicked element; `rankSource` (a generalised path) is used for candidate generation when given, so a per-item-unique attribute of the one node picked cannot end up in the selector. */
|
|
1571
|
+
function fieldPick(html, clicked, rankSource = clicked) {
|
|
1572
|
+
const leaf = clicked.at(-1);
|
|
1573
|
+
const take = leaf === undefined ? 'text' : takeKindFor(leaf);
|
|
1574
|
+
const best = bestCandidate(html, candidatesFor(rankSource));
|
|
1575
|
+
if (best === undefined) throw new Error('no selector matches the picked node — this should not happen, since the node came from this same document');
|
|
1576
|
+
return {
|
|
1577
|
+
selector: best.selector,
|
|
1578
|
+
tier: best.tier,
|
|
1579
|
+
take,
|
|
1580
|
+
matches: best.matches
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
|
|
374
1584
|
/**
|
|
375
1585
|
* The outline model: an `InputRecipe`'s `steps` (and only `steps`; the rest
|
|
376
1586
|
* of the recipe passes through untouched, see `recipe-to-outline.mapper.ts`)
|
|
@@ -752,10 +1962,12 @@ async function saveRecipe(path, recipe) {
|
|
|
752
1962
|
await writeFile(path, `${JSON.stringify(recipe, null, 2)}\n`, 'utf8');
|
|
753
1963
|
}
|
|
754
1964
|
|
|
755
|
-
/** @returns Fresh state for a new server instance: no workspace open, no run, no sockets. */
|
|
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. */
|
|
756
1966
|
function createStudioState() {
|
|
757
1967
|
return {
|
|
758
|
-
sockets: new Set()
|
|
1968
|
+
sockets: new Set(),
|
|
1969
|
+
snapshots: createSnapshotCache(),
|
|
1970
|
+
lastRuns: createLastRunCache()
|
|
759
1971
|
};
|
|
760
1972
|
}
|
|
761
1973
|
/**
|
|
@@ -821,13 +2033,25 @@ async function handleRunSample(state, command) {
|
|
|
821
2033
|
type: 'record',
|
|
822
2034
|
recipeId: command.recipeId,
|
|
823
2035
|
key: record.key,
|
|
824
|
-
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
|
|
825
2048
|
});
|
|
826
2049
|
}
|
|
827
|
-
});
|
|
2050
|
+
}, state.browser);
|
|
828
2051
|
state.activeRun = handle;
|
|
829
2052
|
void handle.result.then(result => {
|
|
830
2053
|
if (state.activeRun === handle) state.activeRun = undefined;
|
|
2054
|
+
recordLastRun(state.lastRuns, command.recipeId, result);
|
|
831
2055
|
broadcast(state, {
|
|
832
2056
|
type: 'run-finished',
|
|
833
2057
|
recipeId: result.recipeId,
|
|
@@ -943,6 +2167,76 @@ function serveStatic(root, pathname, response) {
|
|
|
943
2167
|
createReadStream(file).pipe(response);
|
|
944
2168
|
}
|
|
945
2169
|
|
|
2170
|
+
/**
|
|
2171
|
+
* Handles `take-snapshot`: loads the named input recipe, captures its
|
|
2172
|
+
* snapshot at `command.path` (only `"start"` is captured for now — see
|
|
2173
|
+
* `page-snapshot`'s `take-snapshot.use-case.ts`), and caches it so a later
|
|
2174
|
+
* `verify-selector`/`infer-selector` against the same recipe and path, or
|
|
2175
|
+
* the UI reselecting the same step, does not recapture it.
|
|
2176
|
+
*
|
|
2177
|
+
* @param state - The server state: `folder` must already be set.
|
|
2178
|
+
* @param command - The command.
|
|
2179
|
+
* @returns The rewritten snapshot.
|
|
2180
|
+
* @throws Error when no workspace is open, or the recipe cannot be found or loaded.
|
|
2181
|
+
*/
|
|
2182
|
+
async function handleTakeSnapshot(state, command) {
|
|
2183
|
+
if (state.folder === undefined) throw new Error('open a workspace first');
|
|
2184
|
+
const cached = cachedSnapshot(state.snapshots, command.recipeId, command.path);
|
|
2185
|
+
if (cached !== undefined) return cached;
|
|
2186
|
+
const {
|
|
2187
|
+
input
|
|
2188
|
+
} = await loadRecipePair(state.folder, command.recipeId);
|
|
2189
|
+
const snapshot = await takeSnapshot(input, command.path, state.browser);
|
|
2190
|
+
putSnapshot(state.snapshots, command.recipeId, command.path, snapshot);
|
|
2191
|
+
return snapshot;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
/**
|
|
2195
|
+
* Handles `verify-selector`: runs a candidate through the engine's own
|
|
2196
|
+
* selection (`@opencraw/core`'s `countMatches`) against the cached
|
|
2197
|
+
* snapshot, and, best-effort, against the live page too — issue #91's "flag
|
|
2198
|
+
* a selector that matches the snapshot but not the live page". The live
|
|
2199
|
+
* page is fetched raw (`page-snapshot`'s `fetchStartPage`, the same reach
|
|
2200
|
+
* `take-snapshot` has: only `"start"` today), not through the rewritten
|
|
2201
|
+
* snapshot, so its match count is the site's own, not the studio's.
|
|
2202
|
+
*
|
|
2203
|
+
* @param state - The server state.
|
|
2204
|
+
* @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
|
|
2205
|
+
* @returns The match counts.
|
|
2206
|
+
* @throws Error when no workspace is open, the recipe cannot be found, or `command.path` has no cached snapshot yet.
|
|
2207
|
+
*/
|
|
2208
|
+
async function handleVerifySelector(state, command) {
|
|
2209
|
+
if (state.folder === undefined) throw new Error('open a workspace first');
|
|
2210
|
+
const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
|
|
2211
|
+
if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
|
|
2212
|
+
const snapshotMatches = countMatches(snapshot.html, command.selector);
|
|
2213
|
+
if (command.path !== 'start') return {
|
|
2214
|
+
selector: command.selector,
|
|
2215
|
+
snapshotMatches,
|
|
2216
|
+
liveChecked: false,
|
|
2217
|
+
liveError: 'only the start page can be checked against the live site for now'
|
|
2218
|
+
};
|
|
2219
|
+
try {
|
|
2220
|
+
const {
|
|
2221
|
+
input
|
|
2222
|
+
} = await loadRecipePair(state.folder, command.recipeId);
|
|
2223
|
+
const live = await fetchStartPage(input, state.browser);
|
|
2224
|
+
return {
|
|
2225
|
+
selector: command.selector,
|
|
2226
|
+
snapshotMatches,
|
|
2227
|
+
liveChecked: true,
|
|
2228
|
+
liveMatches: countMatches(live, command.selector)
|
|
2229
|
+
};
|
|
2230
|
+
} catch (error) {
|
|
2231
|
+
return {
|
|
2232
|
+
selector: command.selector,
|
|
2233
|
+
snapshotMatches,
|
|
2234
|
+
liveChecked: false,
|
|
2235
|
+
liveError: error instanceof Error ? error.message : String(error)
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
946
2240
|
/** RFC 6455's fixed handshake suffix. */
|
|
947
2241
|
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
948
2242
|
/**
|
|
@@ -1059,6 +2353,7 @@ async function startStudioServer(options = {}) {
|
|
|
1059
2353
|
const uiRoot = options.uiRoot ?? defaultUiRoot();
|
|
1060
2354
|
const state = createStudioState();
|
|
1061
2355
|
if (options.initialFolder !== undefined) state.folder = options.initialFolder;
|
|
2356
|
+
state.browser = options.browser;
|
|
1062
2357
|
const server = createServer((request, response) => {
|
|
1063
2358
|
void handleRequest(request, response, token, state, uiRoot);
|
|
1064
2359
|
});
|
|
@@ -1155,6 +2450,22 @@ function dispatch(command, state) {
|
|
|
1155
2450
|
{
|
|
1156
2451
|
return handleSaveOutline(state, command);
|
|
1157
2452
|
}
|
|
2453
|
+
case 'take-snapshot':
|
|
2454
|
+
{
|
|
2455
|
+
return handleTakeSnapshot(state, command);
|
|
2456
|
+
}
|
|
2457
|
+
case 'verify-selector':
|
|
2458
|
+
{
|
|
2459
|
+
return handleVerifySelector(state, command);
|
|
2460
|
+
}
|
|
2461
|
+
case 'infer-selector':
|
|
2462
|
+
{
|
|
2463
|
+
return Promise.resolve(handleInferSelector(state, command));
|
|
2464
|
+
}
|
|
2465
|
+
case 'explain-why':
|
|
2466
|
+
{
|
|
2467
|
+
return handleExplainWhy(state, command);
|
|
2468
|
+
}
|
|
1158
2469
|
}
|
|
1159
2470
|
}
|
|
1160
2471
|
function respondJson(response, status, body) {
|
|
@@ -1211,5 +2522,5 @@ async function main(argv) {
|
|
|
1211
2522
|
process.on('SIGTERM', shutdown);
|
|
1212
2523
|
}
|
|
1213
2524
|
|
|
1214
|
-
export { fetchStartPage, fetchStartPageCommandSchema, loadRecipePair, main, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, sentencePartSchema, startPageViewSchema, startStudioServer, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, traceLineEventSchema, 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 };
|
|
1215
2526
|
//# sourceMappingURL=index.esm.js.map
|