browser-commander 0.19.0 → 0.21.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/CHANGELOG.md +156 -0
- package/README.md +216 -32
- package/bin/browser-commander.js +16 -0
- package/eslint.config.js +10 -0
- package/examples/react-test-app/src/App.jsx +24 -23
- package/extension/background.js +14 -0
- package/extension/manifest.json +12 -0
- package/extension/relay-handler.js +353 -0
- package/package.json +25 -6
- package/scripts/run-tests.mjs +4 -2
- package/src/browser/attach/differences.js +131 -0
- package/src/browser/attach/extension-relay.js +402 -0
- package/src/browser/attach/index.js +81 -0
- package/src/browser/attach/snapshot-launch.js +146 -0
- package/src/browser/attach/snapshot.js +374 -0
- package/src/browser/attach/websocket.js +615 -0
- package/src/browser/browser-cookie-cache.js +12 -17
- package/src/browser/browser-cookie-credentials.js +6 -9
- package/src/browser/browser-cookies.js +5 -2
- package/src/browser/cdp-session.js +94 -0
- package/src/browser/connector.js +42 -4
- package/src/browser/debugging-port.js +155 -0
- package/src/browser/launch-options.js +63 -52
- package/src/browser/launch-restrictions.json +147 -0
- package/src/browser/launcher.js +329 -133
- package/src/browser/media.js +5 -0
- package/src/browser/migration/bookmarks.js +73 -0
- package/src/browser/migration/chromium-crypto.js +89 -0
- package/src/browser/migration/cookies.js +155 -0
- package/src/browser/migration/extensions.js +187 -0
- package/src/browser/migration/firefox-bookmarks.js +144 -0
- package/src/browser/migration/firefox-der.js +84 -0
- package/src/browser/migration/firefox-nss.js +281 -0
- package/src/browser/migration/firefox.js +334 -0
- package/src/browser/migration/fs-utils.js +51 -0
- package/src/browser/migration/history.js +103 -0
- package/src/browser/migration/index.js +307 -0
- package/src/browser/migration/os-crypt-keys.js +106 -0
- package/src/browser/migration/passwords.js +171 -0
- package/src/browser/migration/preferences.js +132 -0
- package/src/browser/migration/sqlite-snapshot.js +138 -0
- package/src/browser/open-in-user-browser.js +112 -0
- package/src/browser/parity.js +439 -0
- package/src/browser/profile-directory.js +102 -0
- package/src/browser/real-browser.js +513 -306
- package/src/browser/restrictions.js +148 -0
- package/src/browser/system-browser.js +232 -0
- package/src/browser/webdriver.js +572 -0
- package/src/cli/args.js +253 -0
- package/src/cli/commands.js +332 -0
- package/src/cli/dispatcher.js +99 -0
- package/src/cli/handle-methods.js +136 -0
- package/src/cli/handles.js +273 -0
- package/src/cli/high-level-methods.js +310 -0
- package/src/cli/main.js +67 -0
- package/src/cli/modules.js +100 -0
- package/src/cli/rpc-error.js +77 -0
- package/src/cli/script.js +77 -0
- package/src/cli/serve.js +144 -0
- package/src/cli/sessions.js +195 -0
- package/src/core/constants.js +7 -1
- package/src/core/engine-adapter.js +107 -1
- package/src/core/engine-detection.js +18 -3
- package/src/core/navigation-safety.js +4 -0
- package/src/core/webdriver-conversions.js +347 -0
- package/src/core/webdriver-events.js +229 -0
- package/src/core/webdriver-page.js +716 -0
- package/src/downloads/attach.js +6 -0
- package/src/downloads/manager.js +9 -0
- package/src/downloads/sources.js +71 -0
- package/src/downloads/staging.js +21 -9
- package/src/elements/selectors.js +3 -2
- package/src/elements/visibility.js +4 -0
- package/src/exports.js +61 -2
- package/src/factory.js +19 -4
- package/src/fingerprint/apply.js +6 -0
- package/src/fingerprint/automation-parity.js +7 -8
- package/src/fingerprint/limitations.js +1 -1
- package/src/fingerprint/limitations.json +45 -2
- package/src/parity/harness.js +303 -0
- package/src/parity/probe.js +838 -0
- package/src/parity/version-page.js +144 -0
- package/src/traces/bundle.js +10 -2
- package/src/traces/recorder.js +16 -3
- package/src/utilities/subprocess.js +199 -0
- package/tests/e2e/api-coverage.e2e.test.js +174 -0
- package/tests/e2e/cli.e2e.test.js +160 -0
- package/tests/e2e/downloads.e2e.test.js +79 -2
- package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
- package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
- package/tests/e2e/measure-parity.e2e.test.js +118 -0
- package/tests/e2e/playwright.e2e.test.js +25 -17
- package/tests/e2e/puppeteer.e2e.test.js +17 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/helpers/cli-fakes.js +214 -0
- package/tests/helpers/declared-api.js +80 -0
- package/tests/helpers/e2e-browser.js +40 -14
- package/tests/helpers/fake-chrome.js +124 -0
- package/tests/helpers/migration-fixtures.js +242 -0
- package/tests/helpers/temp-directory.js +24 -0
- package/tests/helpers/trace-fixtures.js +18 -0
- package/tests/helpers/webdriver-mocks.js +183 -0
- package/tests/unit/browser/attach/extension-relay.test.js +360 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
- package/tests/unit/browser/attach/snapshot.test.js +390 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -32
- package/tests/unit/browser/cdp-session.test.js +162 -0
- package/tests/unit/browser/connector.test.js +23 -0
- package/tests/unit/browser/launch-building-blocks.test.js +238 -0
- package/tests/unit/browser/launch-options.test.js +41 -22
- package/tests/unit/browser/launcher.test.js +281 -102
- package/tests/unit/browser/media.test.js +13 -1
- package/tests/unit/browser/migration/bookmarks.test.js +69 -0
- package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
- package/tests/unit/browser/migration/cookies.test.js +94 -0
- package/tests/unit/browser/migration/extensions.test.js +104 -0
- package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
- package/tests/unit/browser/migration/firefox.test.js +139 -0
- package/tests/unit/browser/migration/history.test.js +48 -0
- package/tests/unit/browser/migration/index.test.js +117 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
- package/tests/unit/browser/migration/passwords.test.js +144 -0
- package/tests/unit/browser/migration/preferences.test.js +77 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
- package/tests/unit/browser/open-in-user-browser.test.js +123 -0
- package/tests/unit/browser/parity.test.js +281 -0
- package/tests/unit/browser/real-browser.test.js +458 -33
- package/tests/unit/browser/webdriver.test.js +473 -0
- package/tests/unit/cli/bin.test.js +69 -0
- package/tests/unit/cli/declared-api.test.js +49 -0
- package/tests/unit/cli/dispatcher.test.js +429 -0
- package/tests/unit/cli/handles.test.js +170 -0
- package/tests/unit/cli/main.test.js +409 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- package/tests/unit/downloads/manager.test.js +82 -9
- package/tests/unit/downloads/staging.test.js +24 -2
- package/tests/unit/fingerprint/automation-parity.test.js +17 -4
- package/tests/unit/fingerprint/limitations.test.js +1 -1
- package/tests/unit/parity/harness.test.js +138 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
- package/tests/unit/scripts/feature-parity.test.js +221 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
- package/tests/unit/scripts/pre-commit-config.test.js +2 -1
- package/tests/unit/traces/bundle.test.js +24 -3
- package/tests/unit/traces/links.test.js +7 -0
- package/tests/unit/traces/reader.test.js +11 -4
- package/tests/unit/traces/recorder.test.js +4 -0
- package/tests/unit/traces/viewer.test.js +4 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- package/tests/unit/utilities/subprocess.test.js +80 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parity harness: capture the environment probe from a browser that nothing is
|
|
3
|
+
* automating, then from a browser Browser Commander drives, and diff the two.
|
|
4
|
+
*
|
|
5
|
+
* The reference capture never speaks CDP. The browser is started as a plain
|
|
6
|
+
* child process pointed at a local page; the page runs the probe and POSTs the
|
|
7
|
+
* JSON report back. That is the only way to get a baseline that is genuinely
|
|
8
|
+
* "a real browser" rather than "a browser we are already driving". Automated
|
|
9
|
+
* captures are delivered the same way, so a difference in the diff is a
|
|
10
|
+
* difference in the browser rather than in how the probe was invoked.
|
|
11
|
+
*/
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
|
|
15
|
+
import { startProcess } from '../utilities/subprocess.js';
|
|
16
|
+
import {
|
|
17
|
+
createTemporaryUserDataDir,
|
|
18
|
+
removeUserDataDir,
|
|
19
|
+
} from '../browser/profile-directory.js';
|
|
20
|
+
|
|
21
|
+
/** Read the environment probe shipped with the package. */
|
|
22
|
+
export function readProbeSource() {
|
|
23
|
+
return readFile(new URL('./probe.js', import.meta.url), 'utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function probeExpression(source) {
|
|
27
|
+
return `(${source})()`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function probePage(probeSource, token) {
|
|
31
|
+
return `<!doctype html>
|
|
32
|
+
<html lang="en"><head><meta charset="utf-8"><title>probe</title></head>
|
|
33
|
+
<body><p id="status">running</p>
|
|
34
|
+
<script>
|
|
35
|
+
${probeExpression(probeSource)}
|
|
36
|
+
.then((report) => fetch('/report/${token}', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'content-type': 'application/json' },
|
|
39
|
+
body: JSON.stringify(report),
|
|
40
|
+
}))
|
|
41
|
+
.then(() => { document.getElementById('status').textContent = 'done'; })
|
|
42
|
+
.catch((error) => fetch('/report/${token}', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'content-type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ fatal: String(error && error.stack || error) }),
|
|
46
|
+
}));
|
|
47
|
+
</script></body></html>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Serve the probe page and collect reports POSTed back by page scripts. */
|
|
51
|
+
export async function startProbeServer(probeSource) {
|
|
52
|
+
const reports = new Map();
|
|
53
|
+
const waiters = new Map();
|
|
54
|
+
// The Accept-Language and Sec-CH-UA-* headers are part of the fingerprint and
|
|
55
|
+
// are only observable from the server side, so record them per token.
|
|
56
|
+
const requestHeaders = new Map();
|
|
57
|
+
|
|
58
|
+
const receiveReport = (token, request, response) => {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
request.on('data', (chunk) => chunks.push(chunk));
|
|
61
|
+
request.on('end', () => {
|
|
62
|
+
const report = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
63
|
+
reports.set(token, report);
|
|
64
|
+
waiters.get(token)?.(report);
|
|
65
|
+
waiters.delete(token);
|
|
66
|
+
response.writeHead(204);
|
|
67
|
+
response.end();
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const server = createServer((request, response) => {
|
|
72
|
+
const url = new URL(request.url, 'http://127.0.0.1');
|
|
73
|
+
if (request.method === 'GET' && url.pathname.startsWith('/probe/')) {
|
|
74
|
+
const token = url.pathname.slice('/probe/'.length);
|
|
75
|
+
requestHeaders.set(token, { ...request.headers });
|
|
76
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
77
|
+
response.end(probePage(probeSource, token));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (request.method === 'POST' && url.pathname.startsWith('/report/')) {
|
|
81
|
+
receiveReport(url.pathname.slice('/report/'.length), request, response);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
response.writeHead(404);
|
|
85
|
+
response.end();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const port = await new Promise((resolve) =>
|
|
89
|
+
server.listen(0, '127.0.0.1', () => resolve(server.address().port))
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
port,
|
|
94
|
+
url: (token) => `http://127.0.0.1:${port}/probe/${token}`,
|
|
95
|
+
headersFor: (token) => requestHeaders.get(token) || null,
|
|
96
|
+
waitForReport(token, timeoutMs = 60000) {
|
|
97
|
+
if (reports.has(token)) {
|
|
98
|
+
return Promise.resolve(reports.get(token));
|
|
99
|
+
}
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const timer = setTimeout(
|
|
102
|
+
() => reject(new Error(`timed out waiting for report ${token}`)),
|
|
103
|
+
timeoutMs
|
|
104
|
+
);
|
|
105
|
+
waiters.set(token, (report) => {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
resolve(report);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
async close() {
|
|
112
|
+
server.closeAllConnections?.();
|
|
113
|
+
await new Promise((resolve) => server.close(resolve));
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The command line of a hand-started reference browser: a dedicated profile,
|
|
120
|
+
* headless when asked, and the page to open. Nothing else, so the reference is
|
|
121
|
+
* exactly what a person types.
|
|
122
|
+
*
|
|
123
|
+
* @param {Object} options
|
|
124
|
+
* @param {string} options.userDataDir
|
|
125
|
+
* @param {string} options.url
|
|
126
|
+
* @param {boolean} [options.headless=false]
|
|
127
|
+
* @param {string[]} [options.extraArgs]
|
|
128
|
+
* @returns {string[]}
|
|
129
|
+
*/
|
|
130
|
+
export function buildReferenceArgs({
|
|
131
|
+
userDataDir,
|
|
132
|
+
url,
|
|
133
|
+
headless = false,
|
|
134
|
+
extraArgs = [],
|
|
135
|
+
}) {
|
|
136
|
+
return [
|
|
137
|
+
`--user-data-dir=${userDataDir}`,
|
|
138
|
+
...(headless ? ['--headless=new'] : []),
|
|
139
|
+
...extraArgs,
|
|
140
|
+
url,
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const STDERR_TAIL_BYTES = 4000;
|
|
145
|
+
|
|
146
|
+
/** Keep the last few kilobytes a process wrote, for error messages. */
|
|
147
|
+
function collectTail(channel) {
|
|
148
|
+
let tail = '';
|
|
149
|
+
channel?.on?.('data', (chunk) => {
|
|
150
|
+
tail = (tail + String(chunk)).slice(-STDERR_TAIL_BYTES);
|
|
151
|
+
});
|
|
152
|
+
return () => tail.trim();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function withOutput(error, output) {
|
|
156
|
+
if (output) {
|
|
157
|
+
error.message += `\nbrowser stderr:\n${output}`;
|
|
158
|
+
}
|
|
159
|
+
return error;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reject as soon as the reference browser exits without having reported, so a
|
|
164
|
+
* browser that cannot start (a missing sandbox, a bad switch) fails with its
|
|
165
|
+
* own exit code and stderr instead of a silent report timeout. A clean exit
|
|
166
|
+
* never settles: a launcher script may hand off to a browser that keeps
|
|
167
|
+
* running and still reports.
|
|
168
|
+
*/
|
|
169
|
+
async function exitedBeforeReport(child, executablePath, stderr) {
|
|
170
|
+
const code = await child.exited;
|
|
171
|
+
if (code === 0) {
|
|
172
|
+
return new Promise(() => {});
|
|
173
|
+
}
|
|
174
|
+
throw withOutput(
|
|
175
|
+
new Error(
|
|
176
|
+
`reference browser ${executablePath} exited with code ${code} before reporting`
|
|
177
|
+
),
|
|
178
|
+
stderr()
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Start the browser as an ordinary user would - no CDP, no automation
|
|
184
|
+
* switches - and collect the probe report its page POSTs back.
|
|
185
|
+
*
|
|
186
|
+
* The profile is prepared exactly like the launcher's temporary profile: the
|
|
187
|
+
* `First Run` sentinel and the "What's new" milestone in `Local State` keep
|
|
188
|
+
* the first-run UI and a second tab from opening.
|
|
189
|
+
*
|
|
190
|
+
* @returns {Promise<Object>} The probe report with `commandLine` attached
|
|
191
|
+
*/
|
|
192
|
+
async function captureReferenceAttempt({
|
|
193
|
+
executablePath = process.env.CHROME_PATH || 'google-chrome',
|
|
194
|
+
server,
|
|
195
|
+
token,
|
|
196
|
+
extraArgs = [],
|
|
197
|
+
headless = false,
|
|
198
|
+
timeoutMs = 60000,
|
|
199
|
+
start = startProcess,
|
|
200
|
+
}) {
|
|
201
|
+
const userDataDir = await createTemporaryUserDataDir();
|
|
202
|
+
const args = buildReferenceArgs({
|
|
203
|
+
userDataDir,
|
|
204
|
+
url: server.url(token),
|
|
205
|
+
headless,
|
|
206
|
+
extraArgs,
|
|
207
|
+
});
|
|
208
|
+
const child = start(executablePath, args, { killGrace: 3000 });
|
|
209
|
+
const stderr = collectTail(child.stderr);
|
|
210
|
+
try {
|
|
211
|
+
const report = await Promise.race([
|
|
212
|
+
server.waitForReport(token, timeoutMs).catch((error) => {
|
|
213
|
+
throw withOutput(error, stderr());
|
|
214
|
+
}),
|
|
215
|
+
exitedBeforeReport(child, executablePath, stderr),
|
|
216
|
+
]);
|
|
217
|
+
Object.defineProperty(report, 'commandLine', {
|
|
218
|
+
value: args,
|
|
219
|
+
enumerable: false,
|
|
220
|
+
});
|
|
221
|
+
return report;
|
|
222
|
+
} finally {
|
|
223
|
+
child.kill('SIGTERM');
|
|
224
|
+
await child.exited;
|
|
225
|
+
await removeUserDataDir(userDataDir);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Edge can keep running after its network service crashes during startup,
|
|
231
|
+
* leaving the probe page unable to POST. Retry only that observed transient
|
|
232
|
+
* startup failure; a separate profile and token keep late first-attempt
|
|
233
|
+
* reports from being mistaken for the retry's report.
|
|
234
|
+
*/
|
|
235
|
+
export async function captureReferenceReport(options) {
|
|
236
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
237
|
+
try {
|
|
238
|
+
return await captureReferenceAttempt({
|
|
239
|
+
...options,
|
|
240
|
+
token:
|
|
241
|
+
attempt === 0 ? options.token : `${options.token}-retry-${attempt}`,
|
|
242
|
+
});
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (
|
|
245
|
+
attempt === 0 &&
|
|
246
|
+
error.message.startsWith('timed out waiting for report ') &&
|
|
247
|
+
error.message.includes(
|
|
248
|
+
'Network service crashed or was terminated, restarting service.'
|
|
249
|
+
)
|
|
250
|
+
) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const IGNORED_PATHS = [
|
|
259
|
+
// Window geometry depends on the window manager and on how each engine sizes
|
|
260
|
+
// the first window; it is user-configurable, not an automation artifact.
|
|
261
|
+
/^window\.(innerWidth|innerHeight|outerWidth|outerHeight|screenX|screenY|screenLeft|screenTop)$/u,
|
|
262
|
+
/^viewportRelation\./u,
|
|
263
|
+
/^document\.(referrer|hasFocus|bodyClientHeightIsPositive)$/u,
|
|
264
|
+
/^probeErrors\./u,
|
|
265
|
+
// NetworkInformation.downlink is a rolling bandwidth estimate: two captures
|
|
266
|
+
// from the same real browser disagree, so it carries no automation signal.
|
|
267
|
+
/^connection\.(downlink|rtt)$/u,
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
function isIgnored(pathString, extraIgnores) {
|
|
271
|
+
return (
|
|
272
|
+
IGNORED_PATHS.some((pattern) => pattern.test(pathString)) ||
|
|
273
|
+
extraIgnores.some((pattern) => pattern.test(pathString))
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isPlainRecord(value) {
|
|
278
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Deep diff producing dotted paths, so failures name the exact leaked field. */
|
|
282
|
+
export function diffReports(reference, candidate, { ignore = [] } = {}) {
|
|
283
|
+
const differences = [];
|
|
284
|
+
|
|
285
|
+
const walk = (left, right, trail) => {
|
|
286
|
+
if (isIgnored(trail, ignore)) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (isPlainRecord(left) && isPlainRecord(right)) {
|
|
290
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
291
|
+
for (const key of [...keys].sort()) {
|
|
292
|
+
walk(left[key], right[key], trail ? `${trail}.${key}` : key);
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (JSON.stringify(left) !== JSON.stringify(right)) {
|
|
297
|
+
differences.push({ path: trail, reference: left, candidate: right });
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
walk(reference, candidate, '');
|
|
302
|
+
return differences;
|
|
303
|
+
}
|