browser-commander 0.18.0 → 0.19.0

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.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The continuous half of a trace (issue #93).
3
+ *
4
+ * A checkpoint is a photograph; this is the film between two of them. Keeping
5
+ * it here rather than inside the recorder is what lets it answer the three
6
+ * questions issue #93 said it could not: what happened while a new document was
7
+ * loading, what happened inside an iframe, and what happened to state the DOM
8
+ * never mentions.
9
+ *
10
+ * The in-page half lives in `page-capture.js` and is installed twice over: once
11
+ * into every document that already exists, and once as an init script so every
12
+ * document created from now on is observed before its own code runs.
13
+ */
14
+
15
+ import { withDeadline } from './deadline.js';
16
+ import { framesOf, installInitScript } from './init-script.js';
17
+ import {
18
+ drainMutationsInPage,
19
+ installMutationRecorderInPage,
20
+ RECORDER_GLOBAL,
21
+ stopMutationRecorderInPage,
22
+ } from './page-capture.js';
23
+ import { REDACTED } from './redaction.js';
24
+ import { TRACE_DROP_REASON, TRACE_EVENT } from './schema.js';
25
+
26
+ /** In-page records kept before the recorder starts counting drops instead. */
27
+ export const DEFAULT_MAX_QUEUED_MUTATIONS = 5000;
28
+
29
+ /**
30
+ * Build the recorder's continuous stream.
31
+ *
32
+ * @param {Object} options - Collaborators the stream needs
33
+ * @param {Object} options.page - The page being traced
34
+ * @param {Object} options.bundle - Open trace bundle
35
+ * @param {Function} options.record - Writes one timeline record
36
+ * @param {Object} options.identity - Trace identity, see `identity.js`
37
+ * @param {Function} options.note - Reports something that could not be done
38
+ * @param {Object} options.domOptions - Resolved `dom` options
39
+ * @param {Object} options.privacyOptions - Resolved privacy options
40
+ * @param {Object} options.limits - Resolved limits
41
+ * @param {number} options.captureTimeoutMs - Budget for one drain
42
+ * @param {Function} options.evaluateInPage - Fallback for a page without frames
43
+ * @returns {Object} `{install, installPersistent, drain, stop}`
44
+ */
45
+ export function createMutationStream(options) {
46
+ const {
47
+ page,
48
+ bundle,
49
+ record,
50
+ identity,
51
+ note,
52
+ domOptions,
53
+ privacyOptions,
54
+ limits,
55
+ captureTimeoutMs,
56
+ evaluateInPage,
57
+ } = options;
58
+
59
+ const enabled = Boolean(domOptions.mutations);
60
+
61
+ /** What the in-page recorder is built with, wherever it is installed. */
62
+ const recorderOptions = {
63
+ globalName: RECORDER_GLOBAL,
64
+ redactSelectors: privacyOptions.redactSelectors,
65
+ redacted: REDACTED,
66
+ maxQueued: limits.maxQueuedMutations ?? DEFAULT_MAX_QUEUED_MUTATIONS,
67
+ liveState: domOptions.liveState !== false,
68
+ };
69
+
70
+ /**
71
+ * Run something in every frame of the page.
72
+ *
73
+ * An iframe is a separate document with a separate observer, so a trace that
74
+ * only ever spoke to the main frame recorded nothing that happened inside
75
+ * one. A frame that goes away mid-call is expected rather than a failure: a
76
+ * frame that has already gone has nothing left to report.
77
+ *
78
+ * @param {Function} fn - The function to evaluate
79
+ * @param {*} argument - Its argument
80
+ * @returns {Promise<Array>} One result per frame that answered
81
+ */
82
+ async function evaluateInFrames(fn, argument) {
83
+ const frames = framesOf(page);
84
+ if (frames.length === 0) {
85
+ return [await evaluateInPage(fn, argument)];
86
+ }
87
+ const results = await Promise.all(
88
+ frames.map(async (frame) => {
89
+ try {
90
+ return await frame.evaluate(fn, argument);
91
+ } catch {
92
+ return null;
93
+ }
94
+ })
95
+ );
96
+ return results.filter((result) => result !== null);
97
+ }
98
+
99
+ async function dropped(
100
+ member,
101
+ error,
102
+ reason = TRACE_DROP_REASON.CAPTURE_FAILED
103
+ ) {
104
+ await bundle.drop({
105
+ reason,
106
+ member,
107
+ detail: error.message ?? String(error),
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Install the recorder into the documents that already exist.
113
+ *
114
+ * New documents are covered by the init script; this covers the one the trace
115
+ * started in, and any frame that was already open when it did.
116
+ *
117
+ * @returns {Promise<void>} Resolves once every frame has been asked
118
+ */
119
+ async function install() {
120
+ if (!enabled) {
121
+ return;
122
+ }
123
+ try {
124
+ await evaluateInFrames(installMutationRecorderInPage, recorderOptions);
125
+ } catch (error) {
126
+ await dropped('mutation-recorder', error);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Register the recorder to run before the code of every future document.
132
+ *
133
+ * This is what closes the gap between a navigation and the next checkpoint:
134
+ * without it a new document's own scripts run unobserved, and everything they
135
+ * build looks, on replay, like it had been there all along.
136
+ *
137
+ * @returns {Promise<Function|null>} Detach, when the engine offers one
138
+ */
139
+ async function installPersistent() {
140
+ if (!enabled) {
141
+ return null;
142
+ }
143
+ try {
144
+ return await installInitScript({
145
+ page,
146
+ fn: installMutationRecorderInPage,
147
+ arg: recorderOptions,
148
+ note,
149
+ });
150
+ } catch (error) {
151
+ await dropped('mutation-recorder-init', error);
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Take everything every frame has queued and write it as one interval.
158
+ *
159
+ * @param {number} index - Checkpoint the interval ends at
160
+ * @returns {Promise<string|null>} The member written, when there was anything
161
+ */
162
+ async function drain(index) {
163
+ if (!enabled) {
164
+ return null;
165
+ }
166
+ try {
167
+ const drained = await withDeadline(
168
+ evaluateInFrames(drainMutationsInPage, RECORDER_GLOBAL),
169
+ captureTimeoutMs,
170
+ 'trace mutation drain'
171
+ );
172
+
173
+ const batches = [];
174
+ let over = 0;
175
+ for (const frame of drained) {
176
+ over += frame?.dropped ?? 0;
177
+ for (const batch of frame?.batches ?? []) {
178
+ // The identity of the trace comes from here and the identity of the
179
+ // document comes from inside it: a frame handle is not something an
180
+ // NDJSON record can hold, so each document names itself.
181
+ batches.push({ ...identity.owner(), ...batch });
182
+ }
183
+ }
184
+
185
+ if (over) {
186
+ await dropped(
187
+ 'mutations',
188
+ new Error(`${over} records over the in-page queue limit`),
189
+ TRACE_DROP_REASON.SIZE_LIMIT
190
+ );
191
+ }
192
+
193
+ // Frames are drained in parallel, so the batches come back grouped by
194
+ // frame; one ordered file is what a replay reads.
195
+ batches.sort((left, right) => (left.at ?? 0) - (right.at ?? 0));
196
+
197
+ const member = await bundle.writeMutations(index, batches);
198
+ if (member) {
199
+ await record(TRACE_EVENT.MUTATIONS, {
200
+ member,
201
+ batches: batches.length,
202
+ checkpoint: index,
203
+ frames: drained.length,
204
+ });
205
+ }
206
+ return member;
207
+ } catch (error) {
208
+ await dropped('mutations', error);
209
+ return null;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Switch the recorder off in every document that has one.
215
+ *
216
+ * A recorder left observing would keep queueing into a queue nobody drains.
217
+ *
218
+ * @returns {Promise<void>} Resolves once every frame has been asked
219
+ */
220
+ async function stop() {
221
+ if (!enabled) {
222
+ return;
223
+ }
224
+ try {
225
+ await evaluateInFrames(stopMutationRecorderInPage, RECORDER_GLOBAL);
226
+ } catch (error) {
227
+ note?.(`could not stop the in-page recorder: ${error.message}`);
228
+ }
229
+ }
230
+
231
+ return { install, installPersistent, drain, stop };
232
+ }
@@ -51,17 +51,20 @@ export function interactionTarget(argument) {
51
51
  /**
52
52
  * Subscribe to everything a running trace listens to.
53
53
  *
54
- * @param {Object} options - `{commander, page, eventSources, record, note}`
54
+ * @param {Object} options - `{commander, page, eventSources, record, note, identity}`
55
55
  * @param {Object} [options.commander] - The commander being traced, when there
56
56
  * is one; interactions and managed downloads are reached through it
57
57
  * @param {Object} options.page - The page being traced
58
58
  * @param {string[]} options.eventSources - Sources the caller asked for
59
59
  * @param {Function} options.record - Writes one timeline record
60
60
  * @param {Function} options.note - Reports an observer that could not attach
61
+ * @param {Object} [options.identity] - Trace identity, see `identity.js`; the
62
+ * navigation observer is what moves it on to the next navigation, and the
63
+ * interaction observer is what names an action (issue #93)
61
64
  * @returns {Function[]} One detach function per attached observer
62
65
  */
63
66
  export function attachTimelineObservers(options) {
64
- const { commander, page, eventSources, record, note } = options;
67
+ const { commander, page, eventSources, record, note, identity } = options;
65
68
  const detachers = [];
66
69
 
67
70
  function subscribe(source, attach) {
@@ -90,8 +93,19 @@ export function attachTimelineObservers(options) {
90
93
  if (!manager) {
91
94
  // Without the manager the engine's own event still gives ordering.
92
95
  return onPage('framenavigated', (frame) => {
96
+ const isMain =
97
+ typeof frame?.parentFrame === 'function'
98
+ ? frame.parentFrame() === null
99
+ : true;
100
+ // A record's navigation is the one it happened during, so the counter
101
+ // moves on before the record that reports the move is written.
102
+ const navigationId = isMain
103
+ ? identity?.navigated()
104
+ : identity?.navigationId;
93
105
  void record(TRACE_EVENT.NAVIGATION, {
94
106
  phase: 'framenavigated',
107
+ navigationId,
108
+ mainFrame: isMain,
95
109
  url: typeof frame?.url === 'function' ? frame.url() : String(frame),
96
110
  });
97
111
  });
@@ -99,7 +113,14 @@ export function attachTimelineObservers(options) {
99
113
  const started = (info) =>
100
114
  void record(TRACE_EVENT.NAVIGATION, { phase: 'start', ...info });
101
115
  const completed = (info) =>
102
- void record(TRACE_EVENT.NAVIGATION, { phase: 'complete', ...info });
116
+ void record(TRACE_EVENT.NAVIGATION, {
117
+ phase: 'complete',
118
+ // Everything recorded after this belongs to the document that just
119
+ // loaded, which is the distinction a replay needs to stop mixing two
120
+ // pages' changes together (issue #93).
121
+ navigationId: identity?.navigated(),
122
+ ...info,
123
+ });
103
124
  const changed = (info) =>
104
125
  void record(TRACE_EVENT.NAVIGATION, { phase: 'urlchange', ...info });
105
126
  manager.on('onNavigationStart', started);
@@ -215,9 +236,13 @@ export function attachTimelineObservers(options) {
215
236
  commander[name] = async (...args) => {
216
237
  const startedMs = Date.now();
217
238
  const target = interactionTarget(args[0]);
239
+ // Named before the call, so everything the action causes can be traced
240
+ // back to it even when the action itself ends in an exception.
241
+ const actionId = identity?.nextActionId() ?? null;
218
242
  try {
219
243
  const result = await original(...args);
220
244
  await record(TRACE_EVENT.INTERACTION, {
245
+ actionId,
221
246
  action: name,
222
247
  target,
223
248
  durationMs: Date.now() - startedMs,
@@ -226,6 +251,7 @@ export function attachTimelineObservers(options) {
226
251
  return result;
227
252
  } catch (error) {
228
253
  await record(TRACE_EVENT.INTERACTION, {
254
+ actionId,
229
255
  action: name,
230
256
  target,
231
257
  durationMs: Date.now() - startedMs,
@@ -201,12 +201,22 @@ export function captureSnapshotInPage(options) {
201
201
  }
202
202
 
203
203
  /**
204
- * Install the mutation recorder in the page.
204
+ * Install the in-page recorder.
205
205
  *
206
- * Reinstalling after a navigation is safe and expected: the queue lives on
207
- * the document, which a navigation replaces.
206
+ * Reinstalling after a navigation is safe and expected: the queue lives on the
207
+ * document, which a navigation replaces. Issue #93 is why this also runs as an
208
+ * init script - installing it only from `checkpoint()` left the interval
209
+ * between a navigation and the next checkpoint unrecorded, so a page that
210
+ * built its first screen during `DOMContentLoaded` replayed as if it had
211
+ * always been there.
208
212
  *
209
- * @param {Object} options - `{globalName, redactSelectors, redacted, maxQueued}`
213
+ * Two kinds of change are recorded. DOM mutations come from a
214
+ * `MutationObserver`, with the position information a deterministic replay
215
+ * needs. Live control state - what was typed, checked, selected, focused and
216
+ * scrolled - produces no mutation records at all, so it is observed from the
217
+ * events that carry it and recorded on the same queue, in the same order.
218
+ *
219
+ * @param {Object} options - `{globalName, redactSelectors, redacted, maxQueued, liveState}`
210
220
  * @returns {boolean} True when a recorder is now observing
211
221
  */
212
222
  export function installMutationRecorderInPage(options) {
@@ -215,6 +225,7 @@ export function installMutationRecorderInPage(options) {
215
225
  redactSelectors = [],
216
226
  redacted = '[redacted]',
217
227
  maxQueued = 5000,
228
+ liveState = true,
218
229
  } = options || {};
219
230
 
220
231
  if (window[globalName] && window[globalName].observing) {
@@ -226,6 +237,11 @@ export function installMutationRecorderInPage(options) {
226
237
  queue: [],
227
238
  dropped: 0,
228
239
  sequence: 0,
240
+ // Every document gets its own name, so a batch drained from an iframe is
241
+ // never mistaken for one drained from the page that holds it.
242
+ frameId: `frame-${Math.random().toString(36).slice(2, 10)}`,
243
+ frameName: window.name || null,
244
+ mainFrame: window.top === window,
229
245
  };
230
246
 
231
247
  const isSecret = (node) => {
@@ -264,6 +280,13 @@ export function installMutationRecorderInPage(options) {
264
280
  return steps.join(' > ');
265
281
  };
266
282
 
283
+ const childIndexOf = (parent, node) => {
284
+ if (!parent || !node || !parent.childNodes) {
285
+ return -1;
286
+ }
287
+ return Array.prototype.indexOf.call(parent.childNodes, node);
288
+ };
289
+
267
290
  const describe = (node) => {
268
291
  if (!node) {
269
292
  return null;
@@ -287,15 +310,60 @@ export function installMutationRecorderInPage(options) {
287
310
  };
288
311
  };
289
312
 
290
- const observer = new MutationObserver((records) => {
313
+ /**
314
+ * Describe a node that is no longer where it was.
315
+ *
316
+ * A removed node has no `parentElement`, so `pathOf` can only name the node
317
+ * itself. Its position in the parent it left is what a replay needs, and
318
+ * that is read from the siblings the record still points at.
319
+ *
320
+ * @param {Node} node - The node that moved or was removed
321
+ * @param {MutationRecord} record - The record it came from
322
+ * @returns {Object|null} The description, with `index` when one is knowable
323
+ */
324
+ const describeAt = (node, record) => {
325
+ const described = describe(node);
326
+ if (!described) {
327
+ return null;
328
+ }
329
+ const parent = record.target;
330
+ const own = childIndexOf(parent, node);
331
+ if (own >= 0) {
332
+ described.index = own;
333
+ return described;
334
+ }
335
+ const after = childIndexOf(parent, record.previousSibling);
336
+ if (after >= 0) {
337
+ described.index = after + 1;
338
+ return described;
339
+ }
340
+ const before = childIndexOf(parent, record.nextSibling);
341
+ described.index = before >= 0 ? before : null;
342
+ return described;
343
+ };
344
+
345
+ const push = (batch) => {
291
346
  if (state.queue.length >= maxQueued) {
292
- state.dropped += records.length;
293
- return;
347
+ state.dropped += 1;
348
+ return false;
294
349
  }
295
- const batch = {
350
+ state.queue.push({
296
351
  sequence: ++state.sequence,
297
352
  at: Date.now(),
298
353
  url: location.href,
354
+ frameId: state.frameId,
355
+ mainFrame: state.mainFrame,
356
+ ...batch,
357
+ });
358
+ return true;
359
+ };
360
+
361
+ const observer = new MutationObserver((records) => {
362
+ if (state.queue.length >= maxQueued) {
363
+ state.dropped += records.length;
364
+ return;
365
+ }
366
+ push({
299
367
  records: records.map((record) => {
300
368
  const entry = {
301
369
  kind: record.type,
@@ -317,13 +385,21 @@ export function installMutationRecorderInPage(options) {
317
385
  ? redacted
318
386
  : record.target.nodeValue;
319
387
  } else {
320
- entry.added = [...record.addedNodes].map(describe);
321
- entry.removed = [...record.removedNodes].map(describe);
388
+ // Where a node went is as much of the change as what it was:
389
+ // appending every addition to the end replays an insertion before a
390
+ // sibling in the wrong place, and drops moves and removals entirely.
391
+ entry.added = [...record.addedNodes].map((node) =>
392
+ describeAt(node, record)
393
+ );
394
+ entry.removed = [...record.removedNodes].map((node) =>
395
+ describeAt(node, record)
396
+ );
397
+ entry.previous = describe(record.previousSibling);
398
+ entry.next = describe(record.nextSibling);
322
399
  }
323
400
  return entry;
324
401
  }),
325
- };
326
- state.queue.push(batch);
402
+ });
327
403
  });
328
404
 
329
405
  observer.observe(document, {
@@ -335,8 +411,139 @@ export function installMutationRecorderInPage(options) {
335
411
  characterDataOldValue: true,
336
412
  });
337
413
 
414
+ const liveDetachers = [];
415
+ if (liveState) {
416
+ const lastValues = new WeakMap();
417
+ const lastLive = new WeakMap();
418
+
419
+ // One interaction fires more than one event for the same state: typing
420
+ // into a field and then leaving it fires `input` for each keystroke and
421
+ // `change` for the value it ended at, and a checkbox fires both for the
422
+ // one click. Replaying a state that is already on screen shows nothing, so
423
+ // a repeat of what this element last reported is not recorded.
424
+ const repeats = (element, property, after) => {
425
+ const seen = lastLive.get(element) || {};
426
+ const asText = JSON.stringify(after === undefined ? null : after);
427
+ if (seen[property] === asText) {
428
+ return true;
429
+ }
430
+ seen[property] = asText;
431
+ lastLive.set(element, seen);
432
+ return false;
433
+ };
434
+
435
+ const liveEntry = (target, property, after, before) => ({
436
+ kind: 'live-state',
437
+ property,
438
+ target: describe(target),
439
+ before: before === undefined ? null : before,
440
+ after,
441
+ });
442
+
443
+ const pushLive = (entry) => {
444
+ // Scrolling fires far more often than it changes anything worth
445
+ // replaying, so consecutive scrolls of one element collapse into the
446
+ // position it ended at. Every other property keeps each step, because
447
+ // "stepwise replay must show each state" (issue #93).
448
+ const last = state.queue[state.queue.length - 1];
449
+ const only = last && last.records.length === 1 ? last.records[0] : null;
450
+ if (
451
+ entry.property === 'scroll' &&
452
+ only &&
453
+ only.kind === 'live-state' &&
454
+ only.property === 'scroll' &&
455
+ only.target?.path === entry.target?.path
456
+ ) {
457
+ only.after = entry.after;
458
+ last.at = Date.now();
459
+ return;
460
+ }
461
+ push({ records: [entry] });
462
+ };
463
+
464
+ const valueOf = (element) =>
465
+ isSecret(element)
466
+ ? redacted
467
+ : element.isContentEditable
468
+ ? element.textContent
469
+ : element.value;
470
+
471
+ const onValue = (event) => {
472
+ const element = event.target;
473
+ if (!element || element.nodeType !== 1) {
474
+ return;
475
+ }
476
+ if (element.type === 'checkbox' || element.type === 'radio') {
477
+ if (!repeats(element, 'checked', element.checked)) {
478
+ pushLive(liveEntry(element, 'checked', element.checked));
479
+ }
480
+ return;
481
+ }
482
+ if (element.localName === 'select') {
483
+ const chosen = [...element.selectedOptions].map((option) =>
484
+ isSecret(element) ? redacted : option.value
485
+ );
486
+ if (!repeats(element, 'selected', chosen)) {
487
+ pushLive(liveEntry(element, 'selected', chosen));
488
+ }
489
+ return;
490
+ }
491
+ if (
492
+ element.localName !== 'input' &&
493
+ element.localName !== 'textarea' &&
494
+ !element.isContentEditable
495
+ ) {
496
+ return;
497
+ }
498
+ const after = valueOf(element);
499
+ if (repeats(element, 'value', after)) {
500
+ return;
501
+ }
502
+ const before = lastValues.get(element);
503
+ lastValues.set(element, after);
504
+ pushLive(liveEntry(element, 'value', after, before));
505
+ };
506
+
507
+ const onFocus = (event) => {
508
+ pushLive(liveEntry(event.target, 'focus', event.type === 'focusin'));
509
+ };
510
+
511
+ const onScroll = (event) => {
512
+ const target = event.target;
513
+ const scrolling =
514
+ target === document || target === document.documentElement || !target
515
+ ? null
516
+ : target;
517
+ pushLive({
518
+ kind: 'live-state',
519
+ property: 'scroll',
520
+ target: scrolling ? describe(scrolling) : null,
521
+ before: null,
522
+ after: scrolling
523
+ ? { top: scrolling.scrollTop, left: scrolling.scrollLeft }
524
+ : { top: window.scrollY, left: window.scrollX },
525
+ });
526
+ };
527
+
528
+ const listen = (type, handler) => {
529
+ document.addEventListener(type, handler, true);
530
+ liveDetachers.push(() =>
531
+ document.removeEventListener(type, handler, true)
532
+ );
533
+ };
534
+
535
+ listen('input', onValue);
536
+ listen('change', onValue);
537
+ listen('focusin', onFocus);
538
+ listen('focusout', onFocus);
539
+ listen('scroll', onScroll);
540
+ }
541
+
338
542
  state.stop = () => {
339
543
  observer.disconnect();
544
+ for (const detach of liveDetachers) {
545
+ detach();
546
+ }
340
547
  state.observing = false;
341
548
  };
342
549
 
@@ -348,18 +555,47 @@ export function installMutationRecorderInPage(options) {
348
555
  * Take everything the recorder has queued and leave the queue empty.
349
556
  *
350
557
  * @param {string} globalName - Global the recorder lives on
351
- * @returns {Object} `{batches, dropped}`
558
+ * @returns {Object} `{batches, dropped, installed, frameId, mainFrame}`
352
559
  */
353
560
  export function drainMutationsInPage(globalName) {
354
561
  const state = window[globalName || '__browserCommanderTrace__'];
355
562
  if (!state) {
356
- return { batches: [], dropped: 0, installed: false };
563
+ return {
564
+ batches: [],
565
+ dropped: 0,
566
+ installed: false,
567
+ frameId: null,
568
+ mainFrame: window.top === window,
569
+ url: location.href,
570
+ };
357
571
  }
358
572
  const batches = state.queue;
359
573
  const dropped = state.dropped;
360
574
  state.queue = [];
361
575
  state.dropped = 0;
362
- return { batches, dropped, installed: true };
576
+ return {
577
+ batches,
578
+ dropped,
579
+ installed: true,
580
+ frameId: state.frameId,
581
+ mainFrame: state.mainFrame,
582
+ url: location.href,
583
+ };
584
+ }
585
+
586
+ /**
587
+ * Stop the in-page recorder without waiting for the document to go away.
588
+ *
589
+ * @param {string} globalName - Global the recorder lives on
590
+ * @returns {boolean} True when a recorder was stopped
591
+ */
592
+ export function stopMutationRecorderInPage(globalName) {
593
+ const state = window[globalName || '__browserCommanderTrace__'];
594
+ if (!state || !state.observing) {
595
+ return false;
596
+ }
597
+ state.stop();
598
+ return true;
363
599
  }
364
600
 
365
601
  /* c8 ignore stop */