@speedkit/cli 4.20.2 → 4.20.4
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 +14 -0
- package/README.md +1 -1
- package/dist/services/onboarding/browser/baqend-response.js +21 -3
- package/dist/services/onboarding/browser/executable/headed-user-agent.d.ts +7 -0
- package/dist/services/onboarding/browser/executable/headed-user-agent.js +29 -0
- package/dist/services/onboarding/browser/executable/headed-user-agent.spec.d.ts +1 -0
- package/dist/services/onboarding/browser/executable/headed-user-agent.spec.js +30 -0
- package/dist/services/onboarding/fetch-event-handler.js +10 -3
- package/dist/services/onboarding/fetch-events/speed-kit-service-worker-js.d.ts +8 -0
- package/dist/services/onboarding/fetch-events/speed-kit-service-worker-js.js +9 -0
- package/dist/services/onboarding/onboarding-model.d.ts +9 -0
- package/dist/services/onboarding/onboarding-model.js +47 -2
- package/dist/services/onboarding/onboarding-model.spec.js +71 -1
- package/dist/services/onboarding/onboarding-service-factory.js +5 -4
- package/dist/services/onboarding/onboarding-service.d.ts +25 -1
- package/dist/services/onboarding/onboarding-service.js +70 -1
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [4.20.4](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.20.3...v4.20.4) (2026-08-10)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **onboarding:** detect and repair deployed service worker takeovers ([6d61ae7](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/6d61ae71bab7dc2bbd4926d0c17fc8505d7f523e))
|
|
7
|
+
|
|
8
|
+
## [4.20.3](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.20.2...v4.20.3) (2026-08-07)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **onboarding:** make headless launch match headed ([aa9af3e](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/aa9af3ea69602c82ef30ffa547b33a32092258e5))
|
|
14
|
+
|
|
1
15
|
## [4.20.2](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.20.1...v4.20.2) (2026-08-07)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Makes a value safe for Fetch.fulfillRequest: collapse line breaks and strip
|
|
3
|
+
* characters outside latin-1, either of which Chrome rejects as "Invalid header".
|
|
4
|
+
*/
|
|
5
|
+
function sanitizeHeaderValue(value) {
|
|
6
|
+
return String(value)
|
|
7
|
+
.replace(/[\r\n]+/g, " ")
|
|
8
|
+
.replace(/[^\x20-\xff\t]/g, "?");
|
|
9
|
+
}
|
|
1
10
|
const DEFAULT_HEADERS = [
|
|
2
11
|
{ name: "Date", value: new Date().toUTCString() },
|
|
3
12
|
{ name: "Content-Encoding", value: "identity" },
|
|
@@ -24,19 +33,28 @@ export class BaqendResponse {
|
|
|
24
33
|
: Buffer.from(body).toString("base64");
|
|
25
34
|
}
|
|
26
35
|
setHeader(name, value) {
|
|
36
|
+
const sanitized = sanitizeHeaderValue(value);
|
|
27
37
|
for (const header of this.responseHeaders) {
|
|
28
38
|
if (header.name === name) {
|
|
29
|
-
header.value =
|
|
39
|
+
header.value = sanitized;
|
|
30
40
|
return;
|
|
31
41
|
}
|
|
32
42
|
}
|
|
33
|
-
this.responseHeaders.push({ name, value });
|
|
43
|
+
this.responseHeaders.push({ name, value: sanitized });
|
|
34
44
|
}
|
|
35
45
|
addCustomHeaders(headers) {
|
|
46
|
+
// Fetch.fulfillRequest rejects header values with line breaks or
|
|
47
|
+
// non-latin-1 characters ("Invalid header") — e.g. an error message with
|
|
48
|
+
// a newline placed into x-error left the request hanging in paused state.
|
|
49
|
+
for (const header of headers) {
|
|
50
|
+
header.value = sanitizeHeaderValue(header.value);
|
|
51
|
+
}
|
|
36
52
|
if (!headers?.find((o) => o.name === "Cache-Control")) {
|
|
37
53
|
headers.push({
|
|
38
54
|
name: "Cache-Control",
|
|
39
|
-
|
|
55
|
+
// NOTE: was "nocache, nostore" — invalid directive names that browsers
|
|
56
|
+
// ignore, leaving locally served responses cacheable.
|
|
57
|
+
value: "max-age=0, no-cache, no-store",
|
|
40
58
|
});
|
|
41
59
|
}
|
|
42
60
|
const origin = headers?.find((o) => o.name.toLowerCase() === "origin")?.value;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user agent headed Chrome sends for this build. Headless would otherwise say
|
|
3
|
+
* `HeadlessChrome/<version>`, which some origins treat as a crawler.
|
|
4
|
+
*
|
|
5
|
+
* @param browserVersionString `chrome --version` output, e.g. `Google Chrome for Testing 146.0.7680.165`
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildHeadedUserAgent(browserVersionString: string, platform?: string): string | undefined;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's reduced User-Agent, as documented at https://www.chromium.org/updates/ua-reduction/:
|
|
3
|
+
*
|
|
4
|
+
* Mozilla/5.0 (<unifiedPlatform>) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/<major>.0.0.0 Safari/537.36
|
|
5
|
+
*
|
|
6
|
+
* Only the major version moves — everything after it is frozen at 0.0.0, and the platform tokens are
|
|
7
|
+
* literal values that "will not update even if a user is on an updated operating system or device".
|
|
8
|
+
* So taking the major version from the installed binary keeps this current on its own.
|
|
9
|
+
*/
|
|
10
|
+
const UNIFIED_PLATFORMS = {
|
|
11
|
+
darwin: "Macintosh; Intel Mac OS X 10_15_7",
|
|
12
|
+
win32: "Windows NT 10.0; Win64; x64",
|
|
13
|
+
};
|
|
14
|
+
// Chrome reports X11 for every other *nix build it ships.
|
|
15
|
+
const DEFAULT_UNIFIED_PLATFORM = "X11; Linux x86_64";
|
|
16
|
+
/**
|
|
17
|
+
* The user agent headed Chrome sends for this build. Headless would otherwise say
|
|
18
|
+
* `HeadlessChrome/<version>`, which some origins treat as a crawler.
|
|
19
|
+
*
|
|
20
|
+
* @param browserVersionString `chrome --version` output, e.g. `Google Chrome for Testing 146.0.7680.165`
|
|
21
|
+
*/
|
|
22
|
+
export function buildHeadedUserAgent(browserVersionString, platform = process.platform) {
|
|
23
|
+
const majorVersion = browserVersionString.match(/(\d+)\.\d+\.\d+\.\d+/)?.[1];
|
|
24
|
+
if (!majorVersion) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const unifiedPlatform = UNIFIED_PLATFORMS[platform] ?? DEFAULT_UNIFIED_PLATFORM;
|
|
28
|
+
return `Mozilla/5.0 (${unifiedPlatform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${majorVersion}.0.0.0 Safari/537.36`;
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { expect } from "chai";
|
|
2
|
+
import { describe, it } from "mocha";
|
|
3
|
+
import { buildHeadedUserAgent } from "./headed-user-agent.js";
|
|
4
|
+
const CHROME_VERSION = "Google Chrome for Testing 146.0.7680.165";
|
|
5
|
+
describe("buildHeadedUserAgent", () => {
|
|
6
|
+
it("builds the reduced user agent for Linux", () => {
|
|
7
|
+
expect(buildHeadedUserAgent(CHROME_VERSION, "linux")).to.equal("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36");
|
|
8
|
+
});
|
|
9
|
+
it("builds the reduced user agent for macOS", () => {
|
|
10
|
+
expect(buildHeadedUserAgent(CHROME_VERSION, "darwin")).to.equal("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36");
|
|
11
|
+
});
|
|
12
|
+
it("builds the reduced user agent for Windows", () => {
|
|
13
|
+
expect(buildHeadedUserAgent(CHROME_VERSION, "win32")).to.equal("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36");
|
|
14
|
+
});
|
|
15
|
+
it("falls back to the X11 platform token on other unix builds", () => {
|
|
16
|
+
expect(buildHeadedUserAgent(CHROME_VERSION, "freebsd")).to.include("(X11; Linux x86_64)");
|
|
17
|
+
});
|
|
18
|
+
it("freezes everything after the major version", () => {
|
|
19
|
+
expect(buildHeadedUserAgent("Google Chrome 131.0.6778.86", "linux")).to.include("Chrome/131.0.0.0 ");
|
|
20
|
+
});
|
|
21
|
+
it("reads the version from the Windows ProductName + FileVersion shape", () => {
|
|
22
|
+
expect(buildHeadedUserAgent("Google Chrome 146.0.7680.165", "win32")).to.include("Chrome/146.0.0.0 ");
|
|
23
|
+
});
|
|
24
|
+
it("never advertises the headless token", () => {
|
|
25
|
+
expect(buildHeadedUserAgent(CHROME_VERSION, "linux")).to.not.include("HeadlessChrome");
|
|
26
|
+
});
|
|
27
|
+
it("returns undefined when the version string has no version in it", () => {
|
|
28
|
+
expect(buildHeadedUserAgent("unknown", "linux")).to.equal(undefined);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -41,10 +41,17 @@ export class FetchEventHandler {
|
|
|
41
41
|
if (response.errorObj) {
|
|
42
42
|
this.cli.writeError(`error on ${event.request.url}`);
|
|
43
43
|
this.cli.writeError(`${response.errorObj.message}`);
|
|
44
|
-
return;
|
|
45
44
|
}
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
else {
|
|
46
|
+
this.cli.writeError(`error on ${target.url()}`);
|
|
47
|
+
this.cli.writeError(JSON.stringify(response.errorObj));
|
|
48
|
+
}
|
|
49
|
+
// A request left in paused state hangs until the browser times out.
|
|
50
|
+
// Fail it explicitly so the page sees a network error instead.
|
|
51
|
+
await safe(cdpSession.send("Fetch.failRequest", {
|
|
52
|
+
requestId: event.requestId,
|
|
53
|
+
errorReason: "Failed",
|
|
54
|
+
}));
|
|
48
55
|
});
|
|
49
56
|
}
|
|
50
57
|
canHandlePattern(fetchEventHandler, url, resourceType, requestStage) {
|
|
@@ -11,6 +11,14 @@ export declare class SpeedKitServiceWorkerJs implements FetchRequestPausedEventH
|
|
|
11
11
|
private customerConfig;
|
|
12
12
|
private serviceWorker;
|
|
13
13
|
readonly patterns: Protocol.Fetch.RequestPattern[];
|
|
14
|
+
/**
|
|
15
|
+
* Timestamp of the last locally served sw.js. Chrome's SW update checker
|
|
16
|
+
* fetches scripts in the browser process, outside CDP Fetch interception —
|
|
17
|
+
* an update that slips through installs the DEPLOYED build. A SW version
|
|
18
|
+
* that activates without a recent local serve is such a takeover; the
|
|
19
|
+
* OnboardingService uses this timestamp to detect and repair it.
|
|
20
|
+
*/
|
|
21
|
+
lastServedAt: number;
|
|
14
22
|
constructor(customerConfig: CustomerConfig, serviceWorker: FileInterface);
|
|
15
23
|
handle(): Promise<Partial<Protocol.Fetch.FulfillRequestRequest>>;
|
|
16
24
|
}
|
|
@@ -9,6 +9,14 @@ export class SpeedKitServiceWorkerJs {
|
|
|
9
9
|
customerConfig;
|
|
10
10
|
serviceWorker;
|
|
11
11
|
patterns;
|
|
12
|
+
/**
|
|
13
|
+
* Timestamp of the last locally served sw.js. Chrome's SW update checker
|
|
14
|
+
* fetches scripts in the browser process, outside CDP Fetch interception —
|
|
15
|
+
* an update that slips through installs the DEPLOYED build. A SW version
|
|
16
|
+
* that activates without a recent local serve is such a takeover; the
|
|
17
|
+
* OnboardingService uses this timestamp to detect and repair it.
|
|
18
|
+
*/
|
|
19
|
+
lastServedAt = 0;
|
|
12
20
|
constructor(customerConfig, serviceWorker) {
|
|
13
21
|
this.customerConfig = customerConfig;
|
|
14
22
|
this.serviceWorker = serviceWorker;
|
|
@@ -20,6 +28,7 @@ export class SpeedKitServiceWorkerJs {
|
|
|
20
28
|
];
|
|
21
29
|
}
|
|
22
30
|
async handle() {
|
|
31
|
+
this.lastServedAt = Date.now();
|
|
23
32
|
return new BaqendResponse(this.serviceWorker.getContent(), "text/javascript; charset=utf-8");
|
|
24
33
|
}
|
|
25
34
|
}
|
|
@@ -5,6 +5,8 @@ import { AbortResponse } from "./browser/abort-response.js";
|
|
|
5
5
|
import { task } from "./todo-collector.js";
|
|
6
6
|
import { VanillaPuppeteer } from "puppeteer-extra";
|
|
7
7
|
import { Command } from "@oclif/core/command";
|
|
8
|
+
declare const HEADLESS_DEFAULT_ARGS_TO_DROP: string[];
|
|
9
|
+
export { HEADLESS_DEFAULT_ARGS_TO_DROP };
|
|
8
10
|
export declare const BROWSER_EVENTS: {
|
|
9
11
|
DISCONNECTED: string;
|
|
10
12
|
FETCH_AUTH_REQUIRED: string;
|
|
@@ -14,6 +16,7 @@ export declare const CDP_SESSION: {
|
|
|
14
16
|
EVENTS: {
|
|
15
17
|
SERVICE_WORKER: {
|
|
16
18
|
REGISTRATION_UPDATED: string;
|
|
19
|
+
VERSION_UPDATED: string;
|
|
17
20
|
};
|
|
18
21
|
};
|
|
19
22
|
MESSAGE: {
|
|
@@ -43,6 +46,12 @@ export declare class BrowserContext {
|
|
|
43
46
|
readonly browserArgs: string[];
|
|
44
47
|
readonly chromePath: string;
|
|
45
48
|
constructor(domain: string, browserVersionString?: string, chromeFlags?: string[], userConfig?: UserCliConfig, userAgent?: string, debuggingPort?: boolean, headless?: boolean, debugPort?: number);
|
|
49
|
+
/**
|
|
50
|
+
* Applied as a launch flag rather than over CDP on purpose: a per-page
|
|
51
|
+
* `Network.setUserAgentOverride` never reaches the Service Worker, so Speed Kit's dynamic-block
|
|
52
|
+
* refetch would still leak the headless token and re-poison the merged body.
|
|
53
|
+
*/
|
|
54
|
+
private getHeadlessParityArgs;
|
|
46
55
|
private isChromeBrowser;
|
|
47
56
|
private getBrowserExtensionPath;
|
|
48
57
|
getPuppeteerLaunchOptions(): Parameters<VanillaPuppeteer["launch"]>[0];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Flags } from "@oclif/core";
|
|
2
2
|
import { CLI_CONFIG_IS_TEST, CLI_CONFIG_NAME, CLIParameters, CLIParametersChar, } from "../../models/cli-parameters.js";
|
|
3
3
|
import { BrowserConfig } from "./browser/config/browser-config.js";
|
|
4
|
+
import { buildHeadedUserAgent } from "./browser/executable/headed-user-agent.js";
|
|
4
5
|
import { resolve } from "node:path";
|
|
5
6
|
const DEFAULT_BROWSER_ARGS = [
|
|
6
7
|
"--enable-features=Translate,NetworkService",
|
|
@@ -12,6 +13,25 @@ const DEFAULT_BROWSER_ARGS = [
|
|
|
12
13
|
// triggered by the flag above. Value is arbitrary and only used as an identifier.
|
|
13
14
|
"--test-type=speed-kit-cli",
|
|
14
15
|
];
|
|
16
|
+
// Headless Chrome differs from headed in ways customer bot detection and responsive layout both
|
|
17
|
+
// key on, so a page can render differently — or not at all — depending on the mode. Each entry
|
|
18
|
+
// below erases one measured difference; see HEADLESS_DEFAULT_ARGS_TO_DROP for the counterpart.
|
|
19
|
+
const HEADLESS_PARITY_ARGS = [
|
|
20
|
+
// Headless exposes no pointing device, so `hover: none` / `any-pointer: coarse` send sites down
|
|
21
|
+
// their touch code path.
|
|
22
|
+
"--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4",
|
|
23
|
+
// Headless otherwise pins the screen to 800x600, i.e. a tablet breakpoint. --screen-info is what
|
|
24
|
+
// overrides it; Chrome's own --ozone-override-screen-size is not reachable via ignoreDefaultArgs.
|
|
25
|
+
"--window-size=1920,1200",
|
|
26
|
+
"--screen-info={1920x1200}",
|
|
27
|
+
];
|
|
28
|
+
// Puppeteer-injected headless defaults with no headed equivalent. ignoreDefaultArgs only filters what
|
|
29
|
+
// Puppeteer itself adds, so a spec asserts every entry is still in its defaultArgs.
|
|
30
|
+
const HEADLESS_DEFAULT_ARGS_TO_DROP = [
|
|
31
|
+
// Scrollbar width 0 instead of 15 narrows the layout viewport and skews layout-shift work.
|
|
32
|
+
"--hide-scrollbars",
|
|
33
|
+
];
|
|
34
|
+
export { HEADLESS_DEFAULT_ARGS_TO_DROP };
|
|
15
35
|
export const BROWSER_EVENTS = {
|
|
16
36
|
DISCONNECTED: "disconnected",
|
|
17
37
|
FETCH_AUTH_REQUIRED: "Fetch.authRequired",
|
|
@@ -21,6 +41,7 @@ export const CDP_SESSION = {
|
|
|
21
41
|
EVENTS: {
|
|
22
42
|
SERVICE_WORKER: {
|
|
23
43
|
REGISTRATION_UPDATED: "ServiceWorker.workerRegistrationUpdated",
|
|
44
|
+
VERSION_UPDATED: "ServiceWorker.workerVersionUpdated",
|
|
24
45
|
},
|
|
25
46
|
},
|
|
26
47
|
MESSAGE: {
|
|
@@ -61,7 +82,8 @@ export class BrowserContext {
|
|
|
61
82
|
this.domain = domain;
|
|
62
83
|
this.chromeFlags = chromeFlags || [];
|
|
63
84
|
this.chromePath = resolve(userConfig.chromePath);
|
|
64
|
-
|
|
85
|
+
// Copy: pushing onto the shared module-level array leaked args into every later instance.
|
|
86
|
+
const browserArguments = [...DEFAULT_BROWSER_ARGS];
|
|
65
87
|
if (userConfig?.chromeUserProfilePath) {
|
|
66
88
|
browserArguments.push(`--user-data-dir=${userConfig?.chromeUserProfilePath}`);
|
|
67
89
|
}
|
|
@@ -70,12 +92,32 @@ export class BrowserContext {
|
|
|
70
92
|
const temporaryProfileDirectory = browserConfig.loadProfileTempDir();
|
|
71
93
|
browserArguments.push(`--user-data-dir=${temporaryProfileDirectory}`);
|
|
72
94
|
}
|
|
95
|
+
if (this.headless) {
|
|
96
|
+
this.userAgent =
|
|
97
|
+
this.userAgent ?? buildHeadedUserAgent(browserVersionString);
|
|
98
|
+
}
|
|
73
99
|
this.browserArgs = [
|
|
74
100
|
...browserArguments,
|
|
75
101
|
`--unsafely-treat-insecure-origin-as-secure=${domain}`,
|
|
102
|
+
...this.getHeadlessParityArgs(),
|
|
103
|
+
// Last, so a customer's chromeFlags override our defaults — Chrome takes the last occurrence.
|
|
76
104
|
...this.chromeFlags,
|
|
77
105
|
];
|
|
78
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Applied as a launch flag rather than over CDP on purpose: a per-page
|
|
109
|
+
* `Network.setUserAgentOverride` never reaches the Service Worker, so Speed Kit's dynamic-block
|
|
110
|
+
* refetch would still leak the headless token and re-poison the merged body.
|
|
111
|
+
*/
|
|
112
|
+
getHeadlessParityArgs() {
|
|
113
|
+
if (!this.headless) {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
return [
|
|
117
|
+
...HEADLESS_PARITY_ARGS,
|
|
118
|
+
...(this.userAgent ? [`--user-agent=${this.userAgent}`] : []),
|
|
119
|
+
];
|
|
120
|
+
}
|
|
79
121
|
isChromeBrowser() {
|
|
80
122
|
return !!/chrome/i.test(this.browserVersionString);
|
|
81
123
|
}
|
|
@@ -95,7 +137,10 @@ export class BrowserContext {
|
|
|
95
137
|
args: this.browserArgs,
|
|
96
138
|
// Strip Puppeteer's default `--enable-automation` so Chrome doesn't show
|
|
97
139
|
// the "Chrome is being controlled by automated test software" infobar.
|
|
98
|
-
ignoreDefaultArgs: [
|
|
140
|
+
ignoreDefaultArgs: [
|
|
141
|
+
"--enable-automation",
|
|
142
|
+
...(this.headless ? HEADLESS_DEFAULT_ARGS_TO_DROP : []),
|
|
143
|
+
],
|
|
99
144
|
};
|
|
100
145
|
if (this.userConfig?.chromeExtensionPaths) {
|
|
101
146
|
if (this.isChromeBrowser()) {
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { expect } from "chai";
|
|
2
2
|
import { describe, it } from "mocha";
|
|
3
|
-
import { BrowserContext } from "./onboarding-model.js";
|
|
3
|
+
import { BrowserContext, HEADLESS_DEFAULT_ARGS_TO_DROP, } from "./onboarding-model.js";
|
|
4
|
+
import { buildHeadedUserAgent } from "./browser/executable/headed-user-agent.js";
|
|
4
5
|
// Providing chromeUserProfilePath keeps BrowserContext off the filesystem
|
|
5
6
|
// (it skips the temp-profile branch in the constructor).
|
|
6
7
|
const userConfig = {
|
|
7
8
|
chromePath: "/usr/bin/google-chrome",
|
|
8
9
|
chromeUserProfilePath: "/tmp/sk-test-profile",
|
|
9
10
|
};
|
|
11
|
+
function newContext(headless, { chromeFlags = [], userAgent, browserVersionString = "Google Chrome for Testing 146.0.7680.165", } = {}) {
|
|
12
|
+
return new BrowserContext("example.com", browserVersionString, chromeFlags, userConfig, userAgent, false, headless, undefined);
|
|
13
|
+
}
|
|
14
|
+
function launchArgs(headless, overrides = {}) {
|
|
15
|
+
return newContext(headless, overrides).getPuppeteerLaunchOptions()
|
|
16
|
+
.args;
|
|
17
|
+
}
|
|
10
18
|
function remoteDebuggingArg(debuggingPort, headless, debugPort) {
|
|
11
19
|
const context = new BrowserContext("example.com", "Chrome/120", [], userConfig, undefined, debuggingPort, headless, debugPort);
|
|
12
20
|
const args = context.getPuppeteerLaunchOptions().args;
|
|
@@ -26,3 +34,65 @@ describe("BrowserContext.getPuppeteerLaunchOptions remote debugging port", () =>
|
|
|
26
34
|
expect(remoteDebuggingArg(false, false, 9223)).to.equal(undefined);
|
|
27
35
|
});
|
|
28
36
|
});
|
|
37
|
+
describe("BrowserContext headless/headed parity", () => {
|
|
38
|
+
const userAgentOf = (args) => args.find((arg) => arg.startsWith("--user-agent="));
|
|
39
|
+
it("derives the headed user agent from the installed Chrome version", () => {
|
|
40
|
+
expect(userAgentOf(launchArgs(true))).to.equal(`--user-agent=${buildHeadedUserAgent("Google Chrome for Testing 146.0.7680.165")}`);
|
|
41
|
+
});
|
|
42
|
+
it("never contains the HeadlessChrome token", () => {
|
|
43
|
+
expect(launchArgs(true).join(" ")).to.not.include("HeadlessChrome");
|
|
44
|
+
});
|
|
45
|
+
it("leaves the user agent to Chrome in headed mode", () => {
|
|
46
|
+
expect(userAgentOf(launchArgs(false))).to.equal(undefined);
|
|
47
|
+
});
|
|
48
|
+
it("prefers an explicitly provided user agent", () => {
|
|
49
|
+
expect(userAgentOf(launchArgs(true, { userAgent: "custom-agent" }))).to.equal("--user-agent=custom-agent");
|
|
50
|
+
});
|
|
51
|
+
it("omits the flag when the Chrome version cannot be read", () => {
|
|
52
|
+
expect(userAgentOf(launchArgs(true, { browserVersionString: "unknown" }))).to.equal(undefined);
|
|
53
|
+
});
|
|
54
|
+
it("restores hover and fine-pointer support", () => {
|
|
55
|
+
expect(launchArgs(true)).to.include("--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4");
|
|
56
|
+
});
|
|
57
|
+
it("overrides the 800x600 headless screen", () => {
|
|
58
|
+
expect(launchArgs(true)).to.include.members([
|
|
59
|
+
"--window-size=1920,1200",
|
|
60
|
+
"--screen-info={1920x1200}",
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
it("applies no parity args in headed mode", () => {
|
|
64
|
+
const headedArgs = launchArgs(false);
|
|
65
|
+
expect(headedArgs).to.not.include("--window-size=1920,1200");
|
|
66
|
+
expect(headedArgs.some((arg) => arg.startsWith("--blink-settings="))).to.equal(false);
|
|
67
|
+
});
|
|
68
|
+
it("lets customer chromeFlags win over the parity defaults", () => {
|
|
69
|
+
const args = launchArgs(true, {
|
|
70
|
+
chromeFlags: ["--window-size=390,844", "--user-agent=mobile-agent"],
|
|
71
|
+
});
|
|
72
|
+
// Chrome takes the last occurrence of a switch, so the customer values must come last.
|
|
73
|
+
expect(args.lastIndexOf("--window-size=390,844")).to.be.greaterThan(args.lastIndexOf("--window-size=1920,1200"));
|
|
74
|
+
expect(args.lastIndexOf("--user-agent=mobile-agent")).to.be.greaterThan(args.findIndex((arg) => arg.startsWith("--user-agent=Mozilla/5.0")));
|
|
75
|
+
});
|
|
76
|
+
it("drops the Puppeteer headless defaults that have no headed equivalent", () => {
|
|
77
|
+
expect(newContext(true).getPuppeteerLaunchOptions().ignoreDefaultArgs).to.include.members(["--enable-automation", "--hide-scrollbars"]);
|
|
78
|
+
});
|
|
79
|
+
// ignoreDefaultArgs filters Puppeteer's own args by exact string. Anything Chrome adds itself is
|
|
80
|
+
// unreachable that way, and a Puppeteer bump that renames or drops an arg would silently turn an
|
|
81
|
+
// entry into a no-op — which is how --ozone-override-screen-size got in here in the first place.
|
|
82
|
+
it("only ignores args Puppeteer actually adds", async () => {
|
|
83
|
+
const { defaultArgs } = await import("puppeteer");
|
|
84
|
+
const puppeteerHeadlessArgs = defaultArgs({ headless: true });
|
|
85
|
+
expect(puppeteerHeadlessArgs).to.include.members([
|
|
86
|
+
...HEADLESS_DEFAULT_ARGS_TO_DROP,
|
|
87
|
+
"--enable-automation",
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
it("keeps only --enable-automation ignored in headed mode", () => {
|
|
91
|
+
expect(newContext(false).getPuppeteerLaunchOptions().ignoreDefaultArgs).to.deep.equal(["--enable-automation"]);
|
|
92
|
+
});
|
|
93
|
+
it("does not leak args between instances", () => {
|
|
94
|
+
const first = launchArgs(true).length;
|
|
95
|
+
launchArgs(true);
|
|
96
|
+
expect(launchArgs(true).length).to.equal(first);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -83,9 +83,10 @@ export class OnboardingServiceFactory {
|
|
|
83
83
|
const fileWatcher = new FileWatcher(cli, customerConfig, files, documentHandler, browserContext, cache);
|
|
84
84
|
const athenaClient = (await this.getAthenaClient(customerConfig.app)) || null;
|
|
85
85
|
const messageHandler = await this.prepareMessagehandler(athenaClient, files, customerConfig);
|
|
86
|
-
const
|
|
86
|
+
const speedKitServiceWorkerJs = new SpeedKitServiceWorkerJs(customerConfig, files.getByName(INTEGRATION_FILES.CUSTOM.SW));
|
|
87
|
+
const fetchHandler = await this.getFetchEventHandler(files, customerConfig, documentHandler, cache, cli, browserContext, athenaClient, messageHandler, fileWatcher, speedKitServiceWorkerJs);
|
|
87
88
|
const extensionBridge = new ExtensionBridge(cli);
|
|
88
|
-
return new OnboardingService(browserContext, customerConfig, fetchHandler, fileWatcher, cli, messageHandler, extensionBridge);
|
|
89
|
+
return new OnboardingService(browserContext, customerConfig, fetchHandler, fileWatcher, cli, messageHandler, extensionBridge, speedKitServiceWorkerJs);
|
|
89
90
|
}
|
|
90
91
|
async prepareMessagehandler(athenaClient, files, customerConfig) {
|
|
91
92
|
const messageHandler = new DevtoolsExtensionApi();
|
|
@@ -145,7 +146,7 @@ export class OnboardingServiceFactory {
|
|
|
145
146
|
const configApiContext = new ConfigApiContext(app);
|
|
146
147
|
return new ConfigApiServiceFactory(configApiContext).getService();
|
|
147
148
|
}
|
|
148
|
-
async getFetchEventHandler(files, customerConfig, documentHandler, cache, cli, browserContext, athenaClient, messageHandler, fileWatcher) {
|
|
149
|
+
async getFetchEventHandler(files, customerConfig, documentHandler, cache, cli, browserContext, athenaClient, messageHandler, fileWatcher, speedKitServiceWorkerJs) {
|
|
149
150
|
const configApi = this.getConfigApi(customerConfig.app);
|
|
150
151
|
const serverConfig = await this.getSpeedKitServerConfig(configApi, cli);
|
|
151
152
|
const agent = this.getCrawlerAgent(customerConfig, cli);
|
|
@@ -162,7 +163,7 @@ export class OnboardingServiceFactory {
|
|
|
162
163
|
new SpeedKitInstallHtml(customerConfig, browserContext, new InstallSpeedKitHtmlTemplate()),
|
|
163
164
|
new SpeedKitInstallJs(customerConfig, files.getByName(INTEGRATION_FILES.BUILD.INSTALL)),
|
|
164
165
|
new CustomerServiceWorkerJs(customerConfig),
|
|
165
|
-
|
|
166
|
+
speedKitServiceWorkerJs,
|
|
166
167
|
new DashboardRequest(new Dashboard(customerConfig, parameterQueryBuilder, athenaClient, requestDiffService, new DiffAgainstCurrentPage(DiffService, documentHandler, crawler, cli), cache)),
|
|
167
168
|
];
|
|
168
169
|
if (this.context.local) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CustomerConfig } from "../integration-api/index.js";
|
|
2
2
|
import { BrowserContext } from "./onboarding-model.js";
|
|
3
3
|
import { FetchEventHandler } from "./fetch-event-handler.js";
|
|
4
|
+
import { SpeedKitServiceWorkerJs } from "./fetch-events/speed-kit-service-worker-js.js";
|
|
4
5
|
import { FileWatcher } from "./file-events/file-watcher.js";
|
|
5
6
|
import { CliService } from "../cli/index.js";
|
|
6
7
|
import { DevtoolsExtensionApi } from "./browser/extension/devtools-extension-api.js";
|
|
@@ -16,8 +17,11 @@ export declare class OnboardingService {
|
|
|
16
17
|
private cli;
|
|
17
18
|
private developmentToolsMessages;
|
|
18
19
|
private extensionBridge;
|
|
20
|
+
private speedKitServiceWorkerJs;
|
|
19
21
|
private reinstallOnNextNavigate;
|
|
20
|
-
|
|
22
|
+
private lastForeignSwReinstall;
|
|
23
|
+
private readonly activatedSwVersions;
|
|
24
|
+
constructor(browserContext: BrowserContext, customerConfig: CustomerConfig, fetchEventHandler: FetchEventHandler, fileWatcher: FileWatcher, cli: CliService, developmentToolsMessages: DevtoolsExtensionApi, extensionBridge: ExtensionBridge, speedKitServiceWorkerJs: SpeedKitServiceWorkerJs);
|
|
21
25
|
/**
|
|
22
26
|
* Prepare the puppeteer browser, wire event listeners, navigate to the
|
|
23
27
|
* install page, then block until either the caller aborts the signal
|
|
@@ -37,6 +41,26 @@ export declare class OnboardingService {
|
|
|
37
41
|
private attachExtensionBridge;
|
|
38
42
|
private findPage;
|
|
39
43
|
private prepareCleanState;
|
|
44
|
+
/**
|
|
45
|
+
* Detects when the DEPLOYED service worker takes over the local session and
|
|
46
|
+
* repairs it by reinstalling through the install page.
|
|
47
|
+
*
|
|
48
|
+
* Chrome's SW update checker fetches the wrapper and its imported sw.js in
|
|
49
|
+
* the browser process, where CDP Fetch interception does not apply — those
|
|
50
|
+
* fetches reach production. Because the production wrapper differs from the
|
|
51
|
+
* locally served one, any update check that slips through installs the
|
|
52
|
+
* deployed build. With static routing (`activateStaticRouting`), that is no
|
|
53
|
+
* longer harmless: routes are frozen at install time from the deployed
|
|
54
|
+
* config, so pages only enabled locally bypass the SW entirely (symptom:
|
|
55
|
+
* `responseCause: "SwBooting"` on every navigation of those pages).
|
|
56
|
+
*
|
|
57
|
+
* Only page-initiated `register()` script fetches are interceptable, so a
|
|
58
|
+
* version that activates without `SpeedKitServiceWorkerJs` having served
|
|
59
|
+
* sw.js just before must be a takeover — reinstall via the install page,
|
|
60
|
+
* which goes through the intercepted path again.
|
|
61
|
+
*/
|
|
62
|
+
private registerForeignSwGuard;
|
|
63
|
+
private isForeignSwVersion;
|
|
40
64
|
private reinstallSpeedKit;
|
|
41
65
|
private registerFileWatcher;
|
|
42
66
|
private registerReinstallSpeedKitOnNavigate;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import puppeteerExtra from "puppeteer-extra";
|
|
2
2
|
import { BROWSER_EVENTS, CDP_SESSION, } from "./onboarding-model.js";
|
|
3
3
|
import { safe } from "../../helpers/safe.js";
|
|
4
|
+
// A SW version activating later than this after the last locally served sw.js
|
|
5
|
+
// cannot originate from our interception — it is a deployed-build takeover.
|
|
6
|
+
const LOCAL_SW_SERVE_GRACE_MS = 10_000;
|
|
7
|
+
// Takeovers can retrigger on every update check — don't reinstall in a tight loop.
|
|
8
|
+
const FOREIGN_SW_REINSTALL_COOLDOWN_MS = 30_000;
|
|
4
9
|
/**
|
|
5
10
|
* Represents the OnboardingService which is responsible for setting up the browser to test a config using Puppeteer.
|
|
6
11
|
*/
|
|
@@ -12,8 +17,11 @@ export class OnboardingService {
|
|
|
12
17
|
cli;
|
|
13
18
|
developmentToolsMessages;
|
|
14
19
|
extensionBridge;
|
|
20
|
+
speedKitServiceWorkerJs;
|
|
15
21
|
reinstallOnNextNavigate = false;
|
|
16
|
-
|
|
22
|
+
lastForeignSwReinstall = 0;
|
|
23
|
+
activatedSwVersions = new Set();
|
|
24
|
+
constructor(browserContext, customerConfig, fetchEventHandler, fileWatcher, cli, developmentToolsMessages, extensionBridge, speedKitServiceWorkerJs) {
|
|
17
25
|
this.browserContext = browserContext;
|
|
18
26
|
this.customerConfig = customerConfig;
|
|
19
27
|
this.fetchEventHandler = fetchEventHandler;
|
|
@@ -21,6 +29,7 @@ export class OnboardingService {
|
|
|
21
29
|
this.cli = cli;
|
|
22
30
|
this.developmentToolsMessages = developmentToolsMessages;
|
|
23
31
|
this.extensionBridge = extensionBridge;
|
|
32
|
+
this.speedKitServiceWorkerJs = speedKitServiceWorkerJs;
|
|
24
33
|
}
|
|
25
34
|
/**
|
|
26
35
|
* Prepare the puppeteer browser, wire event listeners, navigate to the
|
|
@@ -102,6 +111,7 @@ export class OnboardingService {
|
|
|
102
111
|
});
|
|
103
112
|
await cdpSession.send("Emulation.clearDeviceMetricsOverride");
|
|
104
113
|
await cdpSession.send("Emulation.clearGeolocationOverride");
|
|
114
|
+
this.registerForeignSwGuard(page, cdpSession);
|
|
105
115
|
safe(() => {
|
|
106
116
|
cdpSession.on(CDP_SESSION.EVENTS.SERVICE_WORKER.REGISTRATION_UPDATED, ({ registrations, }) => {
|
|
107
117
|
for (const registration of registrations) {
|
|
@@ -116,6 +126,65 @@ export class OnboardingService {
|
|
|
116
126
|
});
|
|
117
127
|
});
|
|
118
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Detects when the DEPLOYED service worker takes over the local session and
|
|
131
|
+
* repairs it by reinstalling through the install page.
|
|
132
|
+
*
|
|
133
|
+
* Chrome's SW update checker fetches the wrapper and its imported sw.js in
|
|
134
|
+
* the browser process, where CDP Fetch interception does not apply — those
|
|
135
|
+
* fetches reach production. Because the production wrapper differs from the
|
|
136
|
+
* locally served one, any update check that slips through installs the
|
|
137
|
+
* deployed build. With static routing (`activateStaticRouting`), that is no
|
|
138
|
+
* longer harmless: routes are frozen at install time from the deployed
|
|
139
|
+
* config, so pages only enabled locally bypass the SW entirely (symptom:
|
|
140
|
+
* `responseCause: "SwBooting"` on every navigation of those pages).
|
|
141
|
+
*
|
|
142
|
+
* Only page-initiated `register()` script fetches are interceptable, so a
|
|
143
|
+
* version that activates without `SpeedKitServiceWorkerJs` having served
|
|
144
|
+
* sw.js just before must be a takeover — reinstall via the install page,
|
|
145
|
+
* which goes through the intercepted path again.
|
|
146
|
+
*/
|
|
147
|
+
registerForeignSwGuard(page, cdpSession) {
|
|
148
|
+
cdpSession.on(CDP_SESSION.EVENTS.SERVICE_WORKER.VERSION_UPDATED, async ({ versions, }) => {
|
|
149
|
+
for (const version of versions) {
|
|
150
|
+
if (!this.isForeignSwVersion(version)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
this.cli.writeWarning(`${this.cli.style.yellow("Deployed Service Worker took over")} (version ${version.versionId}) — an update check bypassed the local interception. Reinstalling the local build.`);
|
|
154
|
+
this.developmentToolsMessages.addMessage({
|
|
155
|
+
id: "serviceWorker",
|
|
156
|
+
data: `Deployed Service Worker took over (version ${version.versionId}); reinstalling the local build`,
|
|
157
|
+
});
|
|
158
|
+
if (Date.now() - this.lastForeignSwReinstall <
|
|
159
|
+
FOREIGN_SW_REINSTALL_COOLDOWN_MS) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
this.lastForeignSwReinstall = Date.now();
|
|
163
|
+
await safe(this.reinstallSpeedKit(page, new URL(page.url()).pathname));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
isForeignSwVersion(version) {
|
|
168
|
+
if (version.status !== "activated") {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
// Only guard the Speed Kit wrapper; ignore the empty SWs on disabledScopes
|
|
172
|
+
// and any site-own service workers.
|
|
173
|
+
if (!version.scriptURL.includes(this.customerConfig.swPath)) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (version.scriptURL.includes("empty=1")) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
// Activation events repeat per version (runningStatus changes) — handle each version once.
|
|
180
|
+
if (this.activatedSwVersions.has(version.versionId)) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
this.activatedSwVersions.add(version.versionId);
|
|
184
|
+
// A locally served sw.js right before the activation means we installed it.
|
|
185
|
+
return (Date.now() - this.speedKitServiceWorkerJs.lastServedAt >
|
|
186
|
+
LOCAL_SW_SERVE_GRACE_MS);
|
|
187
|
+
}
|
|
119
188
|
async reinstallSpeedKit(page, pathname) {
|
|
120
189
|
const url = new URL(page.url());
|
|
121
190
|
let target = url.href;
|
package/oclif.manifest.json
CHANGED