@opencraw/studio 0.0.10 → 0.0.12
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 +139 -19
- package/dist/src/page-snapshot/index.d.ts +1 -0
- package/dist/src/page-snapshot/invalidate-stale-snapshot.use-case.d.ts +12 -0
- package/dist/src/page-snapshot/start-point.policy.d.ts +12 -0
- package/dist/src/studio-server/recording.handler.d.ts +4 -1
- package/dist/src/studio-server/workspace.store.d.ts +2 -0
- package/dist/ui/assets/index-CCP7vHMS.js +38 -0
- package/dist/ui/assets/{pdf-4CYQHobd.js → pdf-mQRkgM7N.js} +1 -1
- package/dist/ui/index.html +1 -1
- package/package.json +4 -4
- package/dist/ui/assets/index-CxiFCU_G.js +0 -37
package/dist/index.esm.js
CHANGED
|
@@ -2,7 +2,7 @@ import { join, dirname, basename, extname, normalize, sep, resolve } from 'node:
|
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { BODY_KINDS, HttpClient, BrowserClient, NODE_ID_ATTRIBUTE, rowsOfCells, parseXml, findDeckTables, findGridTables, analyzeTables, parseRegion, regionText, deckRegionText, isDeckDocument, readRecipeSource, parseInputRecipe, parseOutputRecipe, bindRecipeSet, memorySink, createCrawler, traceLine, resolveMissingPolicy, isWorkbookDocument, RecipeBindingError, RecipeValidationError, countMatches, matchesOf, tryParseJson, isPdfDocument, BrowserProfiles } from '@opencraw/core';
|
|
5
|
+
import { BODY_KINDS, HttpClient, BrowserClient, NODE_ID_ATTRIBUTE, sessionStateOf, rowsOfCells, parseXml, findDeckTables, findGridTables, analyzeTables, parseRegion, regionText, deckRegionText, isDeckDocument, readRecipeSource, parseInputRecipe, parseOutputRecipe, bindRecipeSet, memorySink, createCrawler, traceLine, UnknownHookError, resolveMissingPolicy, isWorkbookDocument, RecipeBindingError, RecipeValidationError, countMatches, matchesOf, tryParseJson, isPdfDocument, BrowserProfiles } from '@opencraw/core';
|
|
6
6
|
import { randomBytes, createHash } from 'node:crypto';
|
|
7
7
|
import { execFile } from 'node:child_process';
|
|
8
8
|
import { createRequire } from 'node:module';
|
|
@@ -1119,12 +1119,30 @@ async function takeSnapshot(input, stepPath, browser) {
|
|
|
1119
1119
|
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`);
|
|
1120
1120
|
const point = input.start[0];
|
|
1121
1121
|
if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
|
|
1122
|
-
|
|
1122
|
+
const storageState = await loginState(input, browser);
|
|
1123
|
+
return input.mode === 'web' ? snapshotWeb(point.url, storageState, browser) : snapshotApi(point.url, storageState);
|
|
1123
1124
|
}
|
|
1124
|
-
|
|
1125
|
+
/** What the recipe's own login leaves behind, so the snapshot shows the page as the crawl sees it (issue #185); nothing when it declares no login. */
|
|
1126
|
+
async function loginState(input, browser) {
|
|
1127
|
+
if (input.session === undefined) return undefined;
|
|
1128
|
+
let client;
|
|
1129
|
+
try {
|
|
1130
|
+
return await sessionStateOf(input, {
|
|
1131
|
+
browser: async () => {
|
|
1132
|
+
client = await ensureBrowserLaunch(browser, () => BrowserClient.launch(browser));
|
|
1133
|
+
return client;
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
} finally {
|
|
1137
|
+
await client?.close();
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
async function snapshotWeb(url, storageState, browser) {
|
|
1125
1141
|
const client = await ensureBrowserLaunch(browser, () => BrowserClient.launch(browser));
|
|
1126
1142
|
try {
|
|
1127
|
-
const session = await client.newSession(
|
|
1143
|
+
const session = await client.newSession({
|
|
1144
|
+
storageState
|
|
1145
|
+
});
|
|
1128
1146
|
try {
|
|
1129
1147
|
await session.page.goto(url);
|
|
1130
1148
|
await session.page.evaluate(hiddenMarksScript(HIDDEN_ATTRIBUTE));
|
|
@@ -1143,8 +1161,10 @@ async function snapshotWeb(url, browser) {
|
|
|
1143
1161
|
await client.close();
|
|
1144
1162
|
}
|
|
1145
1163
|
}
|
|
1146
|
-
async function snapshotApi(url) {
|
|
1147
|
-
const client = await HttpClient.open({
|
|
1164
|
+
async function snapshotApi(url, storageState) {
|
|
1165
|
+
const client = await HttpClient.open({
|
|
1166
|
+
storageState
|
|
1167
|
+
});
|
|
1148
1168
|
try {
|
|
1149
1169
|
const response = await client.send({
|
|
1150
1170
|
url
|
|
@@ -1184,6 +1204,13 @@ function cachedSnapshot(cache, recipeId, path) {
|
|
|
1184
1204
|
function putSnapshot(cache, recipeId, path, snapshot) {
|
|
1185
1205
|
cache.entries.set(keyOf(recipeId, path), snapshot);
|
|
1186
1206
|
}
|
|
1207
|
+
/** Drops every cached snapshot of one recipe — called after a save, since the steps up to any path may now capture something different. */
|
|
1208
|
+
function invalidateRecipe(cache, recipeId) {
|
|
1209
|
+
const prefix = `${recipeId}:`;
|
|
1210
|
+
for (const key of cache.entries.keys()) {
|
|
1211
|
+
if (key.startsWith(prefix)) cache.entries.delete(key);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1187
1214
|
function keyOf(recipeId, path) {
|
|
1188
1215
|
return `${recipeId}:${path}`;
|
|
1189
1216
|
}
|
|
@@ -1239,6 +1266,50 @@ async function rereadCsv(url, override) {
|
|
|
1239
1266
|
}
|
|
1240
1267
|
}
|
|
1241
1268
|
|
|
1269
|
+
/** The keys of an input recipe that do not change what its start point shows: the steps and mapping run after it, `output` and `$schema` only name things. */
|
|
1270
|
+
const AFTER_START = new Set(['steps', 'mapping', 'output', '$schema']);
|
|
1271
|
+
/**
|
|
1272
|
+
* Whether an edit changes the page a recipe's start snapshot shows (issue
|
|
1273
|
+
* #159): its start URLs, mode, session, vars, headers… anything but the steps
|
|
1274
|
+
* and mapping that run after it. A pick only adds steps, so it keeps the
|
|
1275
|
+
* snapshot already taken instead of fetching the page again.
|
|
1276
|
+
*
|
|
1277
|
+
* @param before - The recipe as last saved (`undefined` when there was none).
|
|
1278
|
+
* @param after - The recipe being saved.
|
|
1279
|
+
* @returns `true` when the cached start snapshot may now be out of date.
|
|
1280
|
+
*/
|
|
1281
|
+
function startPointChanged(before, after) {
|
|
1282
|
+
return JSON.stringify(startPartOf(before)) !== JSON.stringify(startPartOf(after));
|
|
1283
|
+
}
|
|
1284
|
+
function startPartOf(recipe) {
|
|
1285
|
+
if (typeof recipe !== 'object' || recipe === null) return recipe;
|
|
1286
|
+
return Object.fromEntries(Object.entries(recipe).filter(([key]) => !AFTER_START.has(key)).sort(([a], [b]) => a.localeCompare(b)));
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* Drops a recipe's cached snapshots when the edit about to be saved changes
|
|
1291
|
+
* its start point (issue #159). Call it before the file is written: it reads
|
|
1292
|
+
* the recipe as it is on disk now to compare.
|
|
1293
|
+
*
|
|
1294
|
+
* @param cache - The workspace's snapshot cache.
|
|
1295
|
+
* @param file - The recipe file being saved.
|
|
1296
|
+
* @param next - The recipe being saved.
|
|
1297
|
+
*/
|
|
1298
|
+
async function invalidateStaleSnapshot(cache, file, next) {
|
|
1299
|
+
const id = next?.id;
|
|
1300
|
+
if (typeof id !== 'string') return;
|
|
1301
|
+
if (startPointChanged(await savedRecipe(file), next)) invalidateRecipe(cache, id);
|
|
1302
|
+
}
|
|
1303
|
+
/** The recipe as saved on disk now, or `undefined` when there is no such file (or it is not JSON yet). */
|
|
1304
|
+
async function savedRecipe(file) {
|
|
1305
|
+
try {
|
|
1306
|
+
const text = await readFile(file, 'utf8');
|
|
1307
|
+
return JSON.parse(text);
|
|
1308
|
+
} catch {
|
|
1309
|
+
return undefined;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1242
1313
|
/** A cell reads as an ISO date (`2026-06-01`), an ISO moment (`2026-06-01T09:30:00`) or a bare time of day (`09:30:00`) — the three shapes `workbook-document`'s `read-xlsx.client.ts` writes for a spreadsheet date. */
|
|
1243
1314
|
const ISO_DATE_OR_TIME = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2})?$|^\d{2}:\d{2}:\d{2}$/;
|
|
1244
1315
|
/**
|
|
@@ -1968,7 +2039,7 @@ async function runSample(folder, recipeId, budget, callbacks, browser) {
|
|
|
1968
2039
|
}
|
|
1969
2040
|
}
|
|
1970
2041
|
});
|
|
1971
|
-
const result = runToResult(crawler, set, records, rejectedRecords, steps);
|
|
2042
|
+
const result = runToResult(crawler, set, recipeId, records, rejectedRecords, steps);
|
|
1972
2043
|
return {
|
|
1973
2044
|
stop: () => crawler.close(),
|
|
1974
2045
|
result
|
|
@@ -2012,7 +2083,13 @@ function trackStep(steps, event) {
|
|
|
2012
2083
|
});
|
|
2013
2084
|
}
|
|
2014
2085
|
}
|
|
2015
|
-
|
|
2086
|
+
/**
|
|
2087
|
+
* Awaits the run and shapes its result. A throw from `crawler.run` (an unknown
|
|
2088
|
+
* hook, a recipe that cannot start) becomes the run's `error` instead of a
|
|
2089
|
+
* rejection: nothing awaits `result` but a `.then`, and an unhandled rejection
|
|
2090
|
+
* would take the whole Studio server down (issue #148).
|
|
2091
|
+
*/
|
|
2092
|
+
async function runToResult(crawler, set, recipeId, records, rejectedRecords, steps) {
|
|
2016
2093
|
try {
|
|
2017
2094
|
const report = await crawler.run(set);
|
|
2018
2095
|
const recipe = report.recipes[0];
|
|
@@ -2029,10 +2106,29 @@ async function runToResult(crawler, set, records, rejectedRecords, steps) {
|
|
|
2029
2106
|
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
|
|
2030
2107
|
steps: [...steps.values()]
|
|
2031
2108
|
};
|
|
2109
|
+
} catch (error) {
|
|
2110
|
+
return {
|
|
2111
|
+
recipeId,
|
|
2112
|
+
emitted: 0,
|
|
2113
|
+
rejected: 0,
|
|
2114
|
+
duplicates: 0,
|
|
2115
|
+
durationMs: 0,
|
|
2116
|
+
error: failureMessage(error),
|
|
2117
|
+
records,
|
|
2118
|
+
rejectedRecords,
|
|
2119
|
+
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
|
|
2120
|
+
steps: [...steps.values()]
|
|
2121
|
+
};
|
|
2032
2122
|
} finally {
|
|
2033
2123
|
await crawler.close();
|
|
2034
2124
|
}
|
|
2035
2125
|
}
|
|
2126
|
+
/** What a run that could not start reports. A hook is the author's own code, which Studio never loads (issue #150): say so, instead of leaving "unknown hook" to read as a typo. */
|
|
2127
|
+
function failureMessage(error) {
|
|
2128
|
+
if (error instanceof UnknownHookError) return `${error.message}. Studio does not load hooks, so a recipe that uses one cannot be sample-run here; run it with the cli or the mcp server, or temporarily replace the hook step to preview the rest.`;
|
|
2129
|
+
if (error instanceof Error) return error.message;
|
|
2130
|
+
return String(error);
|
|
2131
|
+
}
|
|
2036
2132
|
|
|
2037
2133
|
/** @returns A fresh, empty cache. */
|
|
2038
2134
|
function createLastRunCache() {
|
|
@@ -4415,13 +4511,13 @@ async function detectNavigation(page, urlBefore) {
|
|
|
4415
4511
|
*/
|
|
4416
4512
|
async function handleStartRecording(state, command) {
|
|
4417
4513
|
if (state.folder === undefined) throw new Error('open a workspace first');
|
|
4418
|
-
await state
|
|
4514
|
+
await settleRecording(state);
|
|
4419
4515
|
const {
|
|
4420
4516
|
input
|
|
4421
4517
|
} = await loadRecipePair(state.folder, command.recipeId);
|
|
4422
4518
|
const point = input.start[0];
|
|
4423
4519
|
if (point === undefined) throw new Error(`recipe "${command.recipeId}" has no start point`);
|
|
4424
|
-
const
|
|
4520
|
+
const opening = openRecorderSession({
|
|
4425
4521
|
startUrl: point.url,
|
|
4426
4522
|
profileDir: state.recorderProfileDir,
|
|
4427
4523
|
browser: state.browser
|
|
@@ -4441,7 +4537,12 @@ async function handleStartRecording(state, command) {
|
|
|
4441
4537
|
});
|
|
4442
4538
|
}
|
|
4443
4539
|
});
|
|
4444
|
-
state.
|
|
4540
|
+
state.openingRecording = opening;
|
|
4541
|
+
try {
|
|
4542
|
+
state.activeRecording = await opening;
|
|
4543
|
+
} finally {
|
|
4544
|
+
state.openingRecording = undefined;
|
|
4545
|
+
}
|
|
4445
4546
|
return {
|
|
4446
4547
|
started: true
|
|
4447
4548
|
};
|
|
@@ -4452,21 +4553,22 @@ async function handleStartRecording(state, command) {
|
|
|
4452
4553
|
* build "make this the login" (`session.bootstrap`) or "keep as steps"
|
|
4453
4554
|
* without asking the server again. Also broadcasts `recording-stopped`, so
|
|
4454
4555
|
* every connected client (not only the one that asked) knows the window
|
|
4455
|
-
* closed.
|
|
4556
|
+
* closed, even when nothing was open (a UI waiting on it would stay stuck).
|
|
4557
|
+
* A stop that arrives while the window is still opening waits for it.
|
|
4456
4558
|
*
|
|
4457
4559
|
* @param state - The server state holding the active recording, if any.
|
|
4458
4560
|
* @param _command - The command (carries nothing beyond its type).
|
|
4459
4561
|
* @returns Every step recorded, in order; empty when nothing was recording.
|
|
4460
4562
|
*/
|
|
4461
4563
|
async function handleStopRecording(state, _command) {
|
|
4564
|
+
await waitForOpening(state);
|
|
4462
4565
|
const recording = state.activeRecording;
|
|
4463
4566
|
state.activeRecording = undefined;
|
|
4464
|
-
if (recording === undefined) return {
|
|
4465
|
-
steps: []
|
|
4466
|
-
};
|
|
4467
4567
|
const {
|
|
4468
4568
|
steps
|
|
4469
|
-
} =
|
|
4569
|
+
} = recording === undefined ? {
|
|
4570
|
+
steps: []
|
|
4571
|
+
} : await recording.stop();
|
|
4470
4572
|
broadcast(state, {
|
|
4471
4573
|
type: 'recording-stopped',
|
|
4472
4574
|
steps
|
|
@@ -4475,6 +4577,20 @@ async function handleStopRecording(state, _command) {
|
|
|
4475
4577
|
steps
|
|
4476
4578
|
};
|
|
4477
4579
|
}
|
|
4580
|
+
/** Closes the recording in progress, waiting first for one still opening (issue #174). */
|
|
4581
|
+
async function settleRecording(state) {
|
|
4582
|
+
await waitForOpening(state);
|
|
4583
|
+
await state.activeRecording?.stop();
|
|
4584
|
+
state.activeRecording = undefined;
|
|
4585
|
+
}
|
|
4586
|
+
/** Waits for a window still opening; a failed open is `start-recording`'s own error to report, not the stopper's. */
|
|
4587
|
+
async function waitForOpening(state) {
|
|
4588
|
+
try {
|
|
4589
|
+
await state.openingRecording;
|
|
4590
|
+
} catch {
|
|
4591
|
+
// reported to the caller of start-recording
|
|
4592
|
+
}
|
|
4593
|
+
}
|
|
4478
4594
|
|
|
4479
4595
|
/**
|
|
4480
4596
|
* Handles `responses-seen`: the JSON responses observed while a recipe's
|
|
@@ -4611,7 +4727,9 @@ async function handleStopRun(state, _command) {
|
|
|
4611
4727
|
* @returns `{ saved: true }` once the write has completed.
|
|
4612
4728
|
*/
|
|
4613
4729
|
async function handleSaveOutline(state, command) {
|
|
4614
|
-
|
|
4730
|
+
const recipe = outlineToRecipe(command.outline);
|
|
4731
|
+
await invalidateStaleSnapshot(state.snapshots, command.path, recipe);
|
|
4732
|
+
await saveRecipe(command.path, recipe);
|
|
4615
4733
|
broadcast(state, {
|
|
4616
4734
|
type: 'workspace-changed'
|
|
4617
4735
|
});
|
|
@@ -4630,7 +4748,9 @@ async function handleSaveOutline(state, command) {
|
|
|
4630
4748
|
* @returns `{ saved: true }` once the write has completed.
|
|
4631
4749
|
*/
|
|
4632
4750
|
async function handleSaveRecipe(state, command) {
|
|
4633
|
-
|
|
4751
|
+
const recipe = command.recipe;
|
|
4752
|
+
await invalidateStaleSnapshot(state.snapshots, command.path, recipe);
|
|
4753
|
+
await saveRecipe(command.path, recipe);
|
|
4634
4754
|
broadcast(state, {
|
|
4635
4755
|
type: 'workspace-changed'
|
|
4636
4756
|
});
|
|
@@ -4918,7 +5038,7 @@ async function startStudioServer(options = {}) {
|
|
|
4918
5038
|
}
|
|
4919
5039
|
async function stop(server, state) {
|
|
4920
5040
|
await state.activeRun?.stop();
|
|
4921
|
-
await state
|
|
5041
|
+
await settleRecording(state);
|
|
4922
5042
|
server.closeAllConnections();
|
|
4923
5043
|
await new Promise((resolve, reject) => {
|
|
4924
5044
|
server.close(error => error === undefined ? resolve() : reject(error));
|
|
@@ -9,4 +9,5 @@ export type { SnapshotCache } from './snapshot.store.js';
|
|
|
9
9
|
export { pdfBytes } from './pdf-bytes.use-case.js';
|
|
10
10
|
export { rereadCsv } from './reread-csv.use-case.js';
|
|
11
11
|
export type { CsvOverride } from './reread-csv.use-case.js';
|
|
12
|
+
export { invalidateStaleSnapshot } from './invalidate-stale-snapshot.use-case.js';
|
|
12
13
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SnapshotCache } from './snapshot.store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Drops a recipe's cached snapshots when the edit about to be saved changes
|
|
4
|
+
* its start point (issue #159). Call it before the file is written: it reads
|
|
5
|
+
* the recipe as it is on disk now to compare.
|
|
6
|
+
*
|
|
7
|
+
* @param cache - The workspace's snapshot cache.
|
|
8
|
+
* @param file - The recipe file being saved.
|
|
9
|
+
* @param next - The recipe being saved.
|
|
10
|
+
*/
|
|
11
|
+
export declare function invalidateStaleSnapshot(cache: SnapshotCache, file: string, next: unknown): Promise<void>;
|
|
12
|
+
//# sourceMappingURL=invalidate-stale-snapshot.use-case.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an edit changes the page a recipe's start snapshot shows (issue
|
|
3
|
+
* #159): its start URLs, mode, session, vars, headers… anything but the steps
|
|
4
|
+
* and mapping that run after it. A pick only adds steps, so it keeps the
|
|
5
|
+
* snapshot already taken instead of fetching the page again.
|
|
6
|
+
*
|
|
7
|
+
* @param before - The recipe as last saved (`undefined` when there was none).
|
|
8
|
+
* @param after - The recipe being saved.
|
|
9
|
+
* @returns `true` when the cached start snapshot may now be out of date.
|
|
10
|
+
*/
|
|
11
|
+
export declare function startPointChanged(before: unknown, after: unknown): boolean;
|
|
12
|
+
//# sourceMappingURL=start-point.policy.d.ts.map
|
|
@@ -21,7 +21,8 @@ export declare function handleStartRecording(state: StudioState, command: StartR
|
|
|
21
21
|
* build "make this the login" (`session.bootstrap`) or "keep as steps"
|
|
22
22
|
* without asking the server again. Also broadcasts `recording-stopped`, so
|
|
23
23
|
* every connected client (not only the one that asked) knows the window
|
|
24
|
-
* closed.
|
|
24
|
+
* closed, even when nothing was open (a UI waiting on it would stay stuck).
|
|
25
|
+
* A stop that arrives while the window is still opening waits for it.
|
|
25
26
|
*
|
|
26
27
|
* @param state - The server state holding the active recording, if any.
|
|
27
28
|
* @param _command - The command (carries nothing beyond its type).
|
|
@@ -30,4 +31,6 @@ export declare function handleStartRecording(state: StudioState, command: StartR
|
|
|
30
31
|
export declare function handleStopRecording(state: StudioState, _command: StopRecordingCommand): Promise<{
|
|
31
32
|
steps: Record<string, unknown>[];
|
|
32
33
|
}>;
|
|
34
|
+
/** Closes the recording in progress, waiting first for one still opening (issue #174). */
|
|
35
|
+
export declare function settleRecording(state: StudioState): Promise<void>;
|
|
33
36
|
//# sourceMappingURL=recording.handler.d.ts.map
|
|
@@ -9,6 +9,8 @@ export interface StudioState {
|
|
|
9
9
|
folder?: string;
|
|
10
10
|
activeRun?: SampleRunHandle;
|
|
11
11
|
activeRecording?: RecorderSessionHandle;
|
|
12
|
+
/** A `start-recording` still opening its window: `stop-recording` and the server's own close wait for it, so a window is never left open behind them (issue #174). */
|
|
13
|
+
openingRecording?: Promise<unknown>;
|
|
12
14
|
sockets: Set<WsConnection>;
|
|
13
15
|
snapshots: SnapshotCache;
|
|
14
16
|
lastRuns: LastRunCache;
|