browser-commander 0.28.0 → 0.30.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.
- package/.jscpd-baseline.json +7 -20
- package/CHANGELOG.md +12 -0
- package/README.md +27 -10
- package/package.json +1 -1
- package/scripts/run-tests.mjs +3 -1
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +140 -65
- package/src/browser/launcher.js +19 -8
- package/src/browser/persistent-session.js +110 -38
- package/src/browser/session-adoption.js +96 -0
- package/src/browser/session-worker.js +12 -0
- package/src/cli/args.js +3 -0
- package/src/cli/commands.js +3 -0
- package/src/cli/high-level-methods.js +7 -0
- package/src/cli/sessions.js +3 -0
- package/src/core/commander-features.js +33 -25
- package/src/core/navigation-safety.js +1 -0
- package/src/core/page-trigger-manager.js +173 -134
- package/src/index.js +2 -0
- package/src/interactions/overlays.js +59 -14
- package/src/traces/bundle.js +41 -3
- package/src/traces/checkpoints.js +7 -3
- package/src/traces/commander-actions.js +146 -0
- package/src/traces/debug-session.js +119 -0
- package/src/traces/drain-timer.js +25 -0
- package/src/traces/links.js +44 -5
- package/src/traces/mutation-stream.js +12 -5
- package/src/traces/network.js +65 -13
- package/src/traces/observers.js +50 -46
- package/src/traces/page-capture.js +65 -21
- package/src/traces/recorder.js +79 -28
- package/src/traces/redaction.js +89 -6
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +10 -113
- package/src/traces/stop.js +1 -0
- package/src/traces/user-actions.js +405 -0
- package/tests/e2e/issue-160.e2e.test.js +291 -0
- package/tests/e2e/issue-162-recording.e2e.test.js +175 -0
- package/tests/e2e/traces.e2e.test.js +4 -1
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +4 -4
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
- package/tests/fixtures/traces/conformance/expected/trace.lino +4 -4
- package/tests/fixtures/traces/conformance/scenario.json +1 -0
- package/tests/helpers/e2e-browser.js +2 -1
- package/tests/unit/browser/issue-160-connector.test.js +157 -0
- package/tests/unit/core/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -0
- package/tests/unit/interactions/overlays.test.js +58 -0
- package/tests/unit/traces/issue-160-privacy.test.js +110 -0
- package/tests/unit/traces/issue-160-rotation.test.js +140 -0
- package/tests/unit/traces/issue-162-defaults.test.js +223 -0
- package/tests/unit/traces/issue-162-user-actions.test.js +181 -0
- package/tests/unit/traces/network.test.js +55 -3
- package/tests/unit/traces/recorder.test.js +4 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import vm from 'node:vm';
|
|
4
|
+
import { installUserActionRecorderInPage } from '../../../src/traces/user-actions.js';
|
|
5
|
+
import { observeCommanderActions } from '../../../src/traces/commander-actions.js';
|
|
6
|
+
import { interactionTarget } from '../../../src/traces/observers.js';
|
|
7
|
+
|
|
8
|
+
function documentFixture(options = {}) {
|
|
9
|
+
const listeners = new Map();
|
|
10
|
+
const window = {
|
|
11
|
+
CSS: { escape: (id) => id },
|
|
12
|
+
getComputedStyle: () => ({ visibility: 'visible' }),
|
|
13
|
+
};
|
|
14
|
+
window.top = window;
|
|
15
|
+
const root = { getElementById: () => null };
|
|
16
|
+
const target = {
|
|
17
|
+
nodeType: 1,
|
|
18
|
+
localName: options.tag ?? 'button',
|
|
19
|
+
id: 'delete',
|
|
20
|
+
type: 'text',
|
|
21
|
+
labels: [],
|
|
22
|
+
value: 'PRIVATE_TYPED_TEXT',
|
|
23
|
+
childNodes: [],
|
|
24
|
+
getRootNode: () => root,
|
|
25
|
+
getClientRects: () => [{}],
|
|
26
|
+
getAttribute: (name) => (name === 'aria-label' ? 'Delete position' : null),
|
|
27
|
+
closest: (selector) =>
|
|
28
|
+
selector === '[data-private]'
|
|
29
|
+
? options.secret
|
|
30
|
+
? target
|
|
31
|
+
: null
|
|
32
|
+
: selector === '#other'
|
|
33
|
+
? null
|
|
34
|
+
: selector === target.localName || selector.includes('button')
|
|
35
|
+
? target
|
|
36
|
+
: null,
|
|
37
|
+
querySelector: () => null,
|
|
38
|
+
};
|
|
39
|
+
target.childNodes.push({
|
|
40
|
+
nodeType: 3,
|
|
41
|
+
nodeValue: options.text ?? 'Delete',
|
|
42
|
+
parentElement: target,
|
|
43
|
+
getRootNode: () => root,
|
|
44
|
+
});
|
|
45
|
+
const context = vm.createContext({
|
|
46
|
+
window,
|
|
47
|
+
location: { href: 'https://example.test/' },
|
|
48
|
+
document: {
|
|
49
|
+
activeElement: target,
|
|
50
|
+
addEventListener: (name, listener) => listeners.set(name, listener),
|
|
51
|
+
removeEventListener: (name) => listeners.delete(name),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
vm.runInContext(
|
|
55
|
+
`(${installUserActionRecorderInPage.toString()})(${JSON.stringify({
|
|
56
|
+
globalName: 'recorder',
|
|
57
|
+
binding: null,
|
|
58
|
+
maxQueued: 2,
|
|
59
|
+
redactSelectors: ['[data-private]'],
|
|
60
|
+
ignoreSelectors: [],
|
|
61
|
+
redacted: '[redacted]',
|
|
62
|
+
})})`,
|
|
63
|
+
context
|
|
64
|
+
);
|
|
65
|
+
const fire = (type, extras = {}) =>
|
|
66
|
+
listeners.get(type)?.({
|
|
67
|
+
type,
|
|
68
|
+
isTrusted: true,
|
|
69
|
+
target,
|
|
70
|
+
composedPath: () => [extras.target ?? target],
|
|
71
|
+
...extras,
|
|
72
|
+
});
|
|
73
|
+
const evaluate = (fn, argument, element = null) => {
|
|
74
|
+
context.argument = argument;
|
|
75
|
+
context.element = element;
|
|
76
|
+
return vm.runInContext(
|
|
77
|
+
`(${fn.toString()})(...${element ? '[element, argument]' : '[argument]'})`,
|
|
78
|
+
context
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
return { window, target, listeners, fire, evaluate, state: window.recorder };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function observeActions(commander, evaluate, name) {
|
|
85
|
+
const recorded = [];
|
|
86
|
+
const detach = observeCommanderActions({
|
|
87
|
+
commander,
|
|
88
|
+
page: { evaluate },
|
|
89
|
+
names: [name],
|
|
90
|
+
targetOf: interactionTarget,
|
|
91
|
+
now: () => 0,
|
|
92
|
+
record: async (kind, event) => recorded.push({ kind, ...event }),
|
|
93
|
+
});
|
|
94
|
+
return { recorded, detach };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
it('records only trusted actions and non-text keys, and stops listeners', () => {
|
|
98
|
+
const { fire, state, listeners } = documentFixture();
|
|
99
|
+
fire('click', { isTrusted: false });
|
|
100
|
+
fire('keydown', { key: 'a' });
|
|
101
|
+
fire('keydown', { key: 'Enter', isComposing: true });
|
|
102
|
+
assert.equal(state.queue.length, 0);
|
|
103
|
+
fire('click');
|
|
104
|
+
fire('keydown', { key: 'Escape' });
|
|
105
|
+
assert.equal(state.queue.length, 2);
|
|
106
|
+
assert.equal(state.queue[0].source, 'user');
|
|
107
|
+
assert.equal(state.queue[0].target.name, 'Delete position');
|
|
108
|
+
assert.equal(state.queue[1].key, 'Escape');
|
|
109
|
+
assert.doesNotMatch(JSON.stringify(state.queue), /PRIVATE_TYPED_TEXT/);
|
|
110
|
+
state.stop();
|
|
111
|
+
assert.equal(listeners.size, 0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('bounds the action queue and redacts private targets before delivery', () => {
|
|
115
|
+
const { fire, state } = documentFixture({
|
|
116
|
+
secret: true,
|
|
117
|
+
text: 'PRIVATE_TARGET',
|
|
118
|
+
});
|
|
119
|
+
for (let i = 0; i < 5; i += 1) {
|
|
120
|
+
fire('click');
|
|
121
|
+
}
|
|
122
|
+
assert.equal(state.queue.length, 2);
|
|
123
|
+
assert.equal(state.dropped, 3);
|
|
124
|
+
assert.doesNotMatch(JSON.stringify(state.queue), /PRIVATE_TARGET/);
|
|
125
|
+
assert.equal(state.queue[0].target.name, '[redacted]');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('never describes typed textarea text and keeps unrelated manual actions during automation', () => {
|
|
129
|
+
const { fire, state, window } = documentFixture({
|
|
130
|
+
tag: 'textarea',
|
|
131
|
+
text: 'PRIVATE_TEXTAREA',
|
|
132
|
+
});
|
|
133
|
+
window.__browserCommanderActions__ = [
|
|
134
|
+
{ selector: '#other', types: ['click'] },
|
|
135
|
+
];
|
|
136
|
+
fire('click');
|
|
137
|
+
assert.equal(state.queue.length, 1);
|
|
138
|
+
assert.equal(state.queue[0].target.text, '');
|
|
139
|
+
assert.doesNotMatch(JSON.stringify(state.queue), /PRIVATE_TEXTAREA/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('attributes element-handle input to the commander and removes its marker', async () => {
|
|
143
|
+
const { fire, state, evaluate, target, window } = documentFixture();
|
|
144
|
+
const element = {
|
|
145
|
+
evaluate: (fn, argument) => evaluate(fn, argument, target),
|
|
146
|
+
};
|
|
147
|
+
const commander = { clickElement: async () => fire('click') };
|
|
148
|
+
const { recorded, detach } = observeActions(
|
|
149
|
+
commander,
|
|
150
|
+
evaluate,
|
|
151
|
+
'clickElement'
|
|
152
|
+
);
|
|
153
|
+
try {
|
|
154
|
+
await commander.clickElement({ locatorOrElement: element });
|
|
155
|
+
assert.equal(state.queue.length, 0);
|
|
156
|
+
assert.equal(recorded[0].source, 'commander');
|
|
157
|
+
assert.equal(window.__browserCommanderActions__.length, 0);
|
|
158
|
+
} finally {
|
|
159
|
+
detach();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('attributes keyboard activation clicks to the commander without suppressing other controls', async () => {
|
|
164
|
+
const { fire, state, evaluate, target } = documentFixture();
|
|
165
|
+
target.contains = (node) => node === target;
|
|
166
|
+
const commander = {
|
|
167
|
+
pressKey: async () => {
|
|
168
|
+
fire('keydown', { key: 'Enter' });
|
|
169
|
+
fire('click', { detail: 0 });
|
|
170
|
+
fire('click', { target: { ...target, closest: () => null } });
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
const { recorded, detach } = observeActions(commander, evaluate, 'pressKey');
|
|
174
|
+
try {
|
|
175
|
+
await commander.pressKey({ key: 'Enter' });
|
|
176
|
+
assert.equal(state.queue.length, 1);
|
|
177
|
+
assert.equal(recorded[0].source, 'commander');
|
|
178
|
+
} finally {
|
|
179
|
+
detach();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
@@ -10,9 +10,15 @@ import {
|
|
|
10
10
|
createFakePage,
|
|
11
11
|
} from '../../helpers/trace-fixtures.js';
|
|
12
12
|
|
|
13
|
+
async function networkFixture() {
|
|
14
|
+
return {
|
|
15
|
+
output: await fs.mkdtemp(path.join(os.tmpdir(), 'commander-network-')),
|
|
16
|
+
page: createFakePage(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
it('records filtered network metadata and bounded redacted bodies in HAR', async () => {
|
|
14
|
-
const output = await
|
|
15
|
-
const page = createFakePage();
|
|
21
|
+
const { output, page } = await networkFixture();
|
|
16
22
|
try {
|
|
17
23
|
const trace = await startTrace({
|
|
18
24
|
commander: createFakeCommander(page),
|
|
@@ -55,7 +61,7 @@ it('records filtered network metadata and bounded redacted bodies in HAR', async
|
|
|
55
61
|
await fs.readFile(path.join(output, 'network.har'), 'utf8')
|
|
56
62
|
);
|
|
57
63
|
assert.equal(har.log.entries.length, 1);
|
|
58
|
-
assert.equal(har.log.entries[0].response.content.text, '
|
|
64
|
+
assert.equal(har.log.entries[0].response.content.text, 'abcd');
|
|
59
65
|
assert.equal(har.log.entries[0].response.content._truncated, true);
|
|
60
66
|
} finally {
|
|
61
67
|
await fs.rm(output, { recursive: true, force: true });
|
|
@@ -95,3 +101,49 @@ it('rejected video startup removes every installed observer', async () => {
|
|
|
95
101
|
await fs.rm(output, { recursive: true, force: true });
|
|
96
102
|
}
|
|
97
103
|
});
|
|
104
|
+
|
|
105
|
+
it('omits binary bytes while preserving reported original size without buffering', async () => {
|
|
106
|
+
const { output, page } = await networkFixture();
|
|
107
|
+
try {
|
|
108
|
+
const trace = await startTrace({
|
|
109
|
+
commander: createFakeCommander(page),
|
|
110
|
+
output,
|
|
111
|
+
initialCheckpoint: false,
|
|
112
|
+
network: { bodies: true, har: true },
|
|
113
|
+
});
|
|
114
|
+
const request = {
|
|
115
|
+
url: () => 'https://example.test/binary',
|
|
116
|
+
method: () => 'GET',
|
|
117
|
+
resourceType: () => 'fetch',
|
|
118
|
+
headers: () => ({}),
|
|
119
|
+
sizes: async () => ({ responseBodySize: 1234 }),
|
|
120
|
+
};
|
|
121
|
+
page.emit('request', request);
|
|
122
|
+
page.emit('response', {
|
|
123
|
+
request: () => request,
|
|
124
|
+
status: () => 200,
|
|
125
|
+
headers: () => ({ 'content-type': 'application/octet-stream' }),
|
|
126
|
+
body: async () => {
|
|
127
|
+
assert.fail('binary body must not be buffered');
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
await trace.stop();
|
|
131
|
+
const events = (
|
|
132
|
+
await fs.readFile(path.join(output, 'events.ndjson'), 'utf8')
|
|
133
|
+
)
|
|
134
|
+
.trim()
|
|
135
|
+
.split('\n')
|
|
136
|
+
.map(JSON.parse);
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
events.find((event) => event.kind === 'network.response').body,
|
|
139
|
+
{ size: 1234, omitted: 'binary' }
|
|
140
|
+
);
|
|
141
|
+
const har = JSON.parse(
|
|
142
|
+
await fs.readFile(path.join(output, 'network.har'), 'utf8')
|
|
143
|
+
);
|
|
144
|
+
assert.equal(har.log.entries[0].response.content.size, 1234);
|
|
145
|
+
assert.equal(har.log.entries[0].response.content.text, undefined);
|
|
146
|
+
} finally {
|
|
147
|
+
await fs.rm(output, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
@@ -45,6 +45,9 @@ describe('trace recorder (issue #87)', () => {
|
|
|
45
45
|
commander,
|
|
46
46
|
page,
|
|
47
47
|
output: path.join(directory.path, 'run'),
|
|
48
|
+
mode: TRACE_MODE.CHECKPOINTS,
|
|
49
|
+
network: false,
|
|
50
|
+
links: false,
|
|
48
51
|
...traceOptions,
|
|
49
52
|
});
|
|
50
53
|
|
|
@@ -332,7 +335,7 @@ describe('trace recorder (issue #87)', () => {
|
|
|
332
335
|
// before a freshly navigated document's own scripts do.
|
|
333
336
|
assert.deepStrictEqual(
|
|
334
337
|
page.state.initScripts.map((script) => script.name),
|
|
335
|
-
['installMutationRecorderInPage']
|
|
338
|
+
['installMutationRecorderInPage', 'installUserActionRecorderInPage']
|
|
336
339
|
);
|
|
337
340
|
});
|
|
338
341
|
|