@opencraw/studio 0.0.10 → 0.0.11
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 +114 -14
- 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, 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';
|
|
@@ -1184,6 +1184,13 @@ function cachedSnapshot(cache, recipeId, path) {
|
|
|
1184
1184
|
function putSnapshot(cache, recipeId, path, snapshot) {
|
|
1185
1185
|
cache.entries.set(keyOf(recipeId, path), snapshot);
|
|
1186
1186
|
}
|
|
1187
|
+
/** Drops every cached snapshot of one recipe — called after a save, since the steps up to any path may now capture something different. */
|
|
1188
|
+
function invalidateRecipe(cache, recipeId) {
|
|
1189
|
+
const prefix = `${recipeId}:`;
|
|
1190
|
+
for (const key of cache.entries.keys()) {
|
|
1191
|
+
if (key.startsWith(prefix)) cache.entries.delete(key);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1187
1194
|
function keyOf(recipeId, path) {
|
|
1188
1195
|
return `${recipeId}:${path}`;
|
|
1189
1196
|
}
|
|
@@ -1239,6 +1246,50 @@ async function rereadCsv(url, override) {
|
|
|
1239
1246
|
}
|
|
1240
1247
|
}
|
|
1241
1248
|
|
|
1249
|
+
/** 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. */
|
|
1250
|
+
const AFTER_START = new Set(['steps', 'mapping', 'output', '$schema']);
|
|
1251
|
+
/**
|
|
1252
|
+
* Whether an edit changes the page a recipe's start snapshot shows (issue
|
|
1253
|
+
* #159): its start URLs, mode, session, vars, headers… anything but the steps
|
|
1254
|
+
* and mapping that run after it. A pick only adds steps, so it keeps the
|
|
1255
|
+
* snapshot already taken instead of fetching the page again.
|
|
1256
|
+
*
|
|
1257
|
+
* @param before - The recipe as last saved (`undefined` when there was none).
|
|
1258
|
+
* @param after - The recipe being saved.
|
|
1259
|
+
* @returns `true` when the cached start snapshot may now be out of date.
|
|
1260
|
+
*/
|
|
1261
|
+
function startPointChanged(before, after) {
|
|
1262
|
+
return JSON.stringify(startPartOf(before)) !== JSON.stringify(startPartOf(after));
|
|
1263
|
+
}
|
|
1264
|
+
function startPartOf(recipe) {
|
|
1265
|
+
if (typeof recipe !== 'object' || recipe === null) return recipe;
|
|
1266
|
+
return Object.fromEntries(Object.entries(recipe).filter(([key]) => !AFTER_START.has(key)).sort(([a], [b]) => a.localeCompare(b)));
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Drops a recipe's cached snapshots when the edit about to be saved changes
|
|
1271
|
+
* its start point (issue #159). Call it before the file is written: it reads
|
|
1272
|
+
* the recipe as it is on disk now to compare.
|
|
1273
|
+
*
|
|
1274
|
+
* @param cache - The workspace's snapshot cache.
|
|
1275
|
+
* @param file - The recipe file being saved.
|
|
1276
|
+
* @param next - The recipe being saved.
|
|
1277
|
+
*/
|
|
1278
|
+
async function invalidateStaleSnapshot(cache, file, next) {
|
|
1279
|
+
const id = next?.id;
|
|
1280
|
+
if (typeof id !== 'string') return;
|
|
1281
|
+
if (startPointChanged(await savedRecipe(file), next)) invalidateRecipe(cache, id);
|
|
1282
|
+
}
|
|
1283
|
+
/** The recipe as saved on disk now, or `undefined` when there is no such file (or it is not JSON yet). */
|
|
1284
|
+
async function savedRecipe(file) {
|
|
1285
|
+
try {
|
|
1286
|
+
const text = await readFile(file, 'utf8');
|
|
1287
|
+
return JSON.parse(text);
|
|
1288
|
+
} catch {
|
|
1289
|
+
return undefined;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1242
1293
|
/** 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
1294
|
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
1295
|
/**
|
|
@@ -1968,7 +2019,7 @@ async function runSample(folder, recipeId, budget, callbacks, browser) {
|
|
|
1968
2019
|
}
|
|
1969
2020
|
}
|
|
1970
2021
|
});
|
|
1971
|
-
const result = runToResult(crawler, set, records, rejectedRecords, steps);
|
|
2022
|
+
const result = runToResult(crawler, set, recipeId, records, rejectedRecords, steps);
|
|
1972
2023
|
return {
|
|
1973
2024
|
stop: () => crawler.close(),
|
|
1974
2025
|
result
|
|
@@ -2012,7 +2063,13 @@ function trackStep(steps, event) {
|
|
|
2012
2063
|
});
|
|
2013
2064
|
}
|
|
2014
2065
|
}
|
|
2015
|
-
|
|
2066
|
+
/**
|
|
2067
|
+
* Awaits the run and shapes its result. A throw from `crawler.run` (an unknown
|
|
2068
|
+
* hook, a recipe that cannot start) becomes the run's `error` instead of a
|
|
2069
|
+
* rejection: nothing awaits `result` but a `.then`, and an unhandled rejection
|
|
2070
|
+
* would take the whole Studio server down (issue #148).
|
|
2071
|
+
*/
|
|
2072
|
+
async function runToResult(crawler, set, recipeId, records, rejectedRecords, steps) {
|
|
2016
2073
|
try {
|
|
2017
2074
|
const report = await crawler.run(set);
|
|
2018
2075
|
const recipe = report.recipes[0];
|
|
@@ -2029,10 +2086,29 @@ async function runToResult(crawler, set, records, rejectedRecords, steps) {
|
|
|
2029
2086
|
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
|
|
2030
2087
|
steps: [...steps.values()]
|
|
2031
2088
|
};
|
|
2089
|
+
} catch (error) {
|
|
2090
|
+
return {
|
|
2091
|
+
recipeId,
|
|
2092
|
+
emitted: 0,
|
|
2093
|
+
rejected: 0,
|
|
2094
|
+
duplicates: 0,
|
|
2095
|
+
durationMs: 0,
|
|
2096
|
+
error: failureMessage(error),
|
|
2097
|
+
records,
|
|
2098
|
+
rejectedRecords,
|
|
2099
|
+
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
|
|
2100
|
+
steps: [...steps.values()]
|
|
2101
|
+
};
|
|
2032
2102
|
} finally {
|
|
2033
2103
|
await crawler.close();
|
|
2034
2104
|
}
|
|
2035
2105
|
}
|
|
2106
|
+
/** 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. */
|
|
2107
|
+
function failureMessage(error) {
|
|
2108
|
+
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.`;
|
|
2109
|
+
if (error instanceof Error) return error.message;
|
|
2110
|
+
return String(error);
|
|
2111
|
+
}
|
|
2036
2112
|
|
|
2037
2113
|
/** @returns A fresh, empty cache. */
|
|
2038
2114
|
function createLastRunCache() {
|
|
@@ -4415,13 +4491,13 @@ async function detectNavigation(page, urlBefore) {
|
|
|
4415
4491
|
*/
|
|
4416
4492
|
async function handleStartRecording(state, command) {
|
|
4417
4493
|
if (state.folder === undefined) throw new Error('open a workspace first');
|
|
4418
|
-
await state
|
|
4494
|
+
await settleRecording(state);
|
|
4419
4495
|
const {
|
|
4420
4496
|
input
|
|
4421
4497
|
} = await loadRecipePair(state.folder, command.recipeId);
|
|
4422
4498
|
const point = input.start[0];
|
|
4423
4499
|
if (point === undefined) throw new Error(`recipe "${command.recipeId}" has no start point`);
|
|
4424
|
-
const
|
|
4500
|
+
const opening = openRecorderSession({
|
|
4425
4501
|
startUrl: point.url,
|
|
4426
4502
|
profileDir: state.recorderProfileDir,
|
|
4427
4503
|
browser: state.browser
|
|
@@ -4441,7 +4517,12 @@ async function handleStartRecording(state, command) {
|
|
|
4441
4517
|
});
|
|
4442
4518
|
}
|
|
4443
4519
|
});
|
|
4444
|
-
state.
|
|
4520
|
+
state.openingRecording = opening;
|
|
4521
|
+
try {
|
|
4522
|
+
state.activeRecording = await opening;
|
|
4523
|
+
} finally {
|
|
4524
|
+
state.openingRecording = undefined;
|
|
4525
|
+
}
|
|
4445
4526
|
return {
|
|
4446
4527
|
started: true
|
|
4447
4528
|
};
|
|
@@ -4452,21 +4533,22 @@ async function handleStartRecording(state, command) {
|
|
|
4452
4533
|
* build "make this the login" (`session.bootstrap`) or "keep as steps"
|
|
4453
4534
|
* without asking the server again. Also broadcasts `recording-stopped`, so
|
|
4454
4535
|
* every connected client (not only the one that asked) knows the window
|
|
4455
|
-
* closed.
|
|
4536
|
+
* closed, even when nothing was open (a UI waiting on it would stay stuck).
|
|
4537
|
+
* A stop that arrives while the window is still opening waits for it.
|
|
4456
4538
|
*
|
|
4457
4539
|
* @param state - The server state holding the active recording, if any.
|
|
4458
4540
|
* @param _command - The command (carries nothing beyond its type).
|
|
4459
4541
|
* @returns Every step recorded, in order; empty when nothing was recording.
|
|
4460
4542
|
*/
|
|
4461
4543
|
async function handleStopRecording(state, _command) {
|
|
4544
|
+
await waitForOpening(state);
|
|
4462
4545
|
const recording = state.activeRecording;
|
|
4463
4546
|
state.activeRecording = undefined;
|
|
4464
|
-
if (recording === undefined) return {
|
|
4465
|
-
steps: []
|
|
4466
|
-
};
|
|
4467
4547
|
const {
|
|
4468
4548
|
steps
|
|
4469
|
-
} =
|
|
4549
|
+
} = recording === undefined ? {
|
|
4550
|
+
steps: []
|
|
4551
|
+
} : await recording.stop();
|
|
4470
4552
|
broadcast(state, {
|
|
4471
4553
|
type: 'recording-stopped',
|
|
4472
4554
|
steps
|
|
@@ -4475,6 +4557,20 @@ async function handleStopRecording(state, _command) {
|
|
|
4475
4557
|
steps
|
|
4476
4558
|
};
|
|
4477
4559
|
}
|
|
4560
|
+
/** Closes the recording in progress, waiting first for one still opening (issue #174). */
|
|
4561
|
+
async function settleRecording(state) {
|
|
4562
|
+
await waitForOpening(state);
|
|
4563
|
+
await state.activeRecording?.stop();
|
|
4564
|
+
state.activeRecording = undefined;
|
|
4565
|
+
}
|
|
4566
|
+
/** Waits for a window still opening; a failed open is `start-recording`'s own error to report, not the stopper's. */
|
|
4567
|
+
async function waitForOpening(state) {
|
|
4568
|
+
try {
|
|
4569
|
+
await state.openingRecording;
|
|
4570
|
+
} catch {
|
|
4571
|
+
// reported to the caller of start-recording
|
|
4572
|
+
}
|
|
4573
|
+
}
|
|
4478
4574
|
|
|
4479
4575
|
/**
|
|
4480
4576
|
* Handles `responses-seen`: the JSON responses observed while a recipe's
|
|
@@ -4611,7 +4707,9 @@ async function handleStopRun(state, _command) {
|
|
|
4611
4707
|
* @returns `{ saved: true }` once the write has completed.
|
|
4612
4708
|
*/
|
|
4613
4709
|
async function handleSaveOutline(state, command) {
|
|
4614
|
-
|
|
4710
|
+
const recipe = outlineToRecipe(command.outline);
|
|
4711
|
+
await invalidateStaleSnapshot(state.snapshots, command.path, recipe);
|
|
4712
|
+
await saveRecipe(command.path, recipe);
|
|
4615
4713
|
broadcast(state, {
|
|
4616
4714
|
type: 'workspace-changed'
|
|
4617
4715
|
});
|
|
@@ -4630,7 +4728,9 @@ async function handleSaveOutline(state, command) {
|
|
|
4630
4728
|
* @returns `{ saved: true }` once the write has completed.
|
|
4631
4729
|
*/
|
|
4632
4730
|
async function handleSaveRecipe(state, command) {
|
|
4633
|
-
|
|
4731
|
+
const recipe = command.recipe;
|
|
4732
|
+
await invalidateStaleSnapshot(state.snapshots, command.path, recipe);
|
|
4733
|
+
await saveRecipe(command.path, recipe);
|
|
4634
4734
|
broadcast(state, {
|
|
4635
4735
|
type: 'workspace-changed'
|
|
4636
4736
|
});
|
|
@@ -4918,7 +5018,7 @@ async function startStudioServer(options = {}) {
|
|
|
4918
5018
|
}
|
|
4919
5019
|
async function stop(server, state) {
|
|
4920
5020
|
await state.activeRun?.stop();
|
|
4921
|
-
await state
|
|
5021
|
+
await settleRecording(state);
|
|
4922
5022
|
server.closeAllConnections();
|
|
4923
5023
|
await new Promise((resolve, reject) => {
|
|
4924
5024
|
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;
|