@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 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
- return input.mode === 'web' ? snapshotWeb(point.url, browser) : snapshotApi(point.url);
1122
+ const storageState = await loginState(input, browser);
1123
+ return input.mode === 'web' ? snapshotWeb(point.url, storageState, browser) : snapshotApi(point.url, storageState);
1123
1124
  }
1124
- async function snapshotWeb(url, browser) {
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
- async function runToResult(crawler, set, records, rejectedRecords, steps) {
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.activeRecording?.stop();
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 handle = await openRecorderSession({
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.activeRecording = handle;
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
- } = await recording.stop();
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
- await saveRecipe(command.path, outlineToRecipe(command.outline));
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
- await saveRecipe(command.path, command.recipe);
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.activeRecording?.stop();
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;