snappy-prerender 2.6.0-rc.3 → 2.7.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 +191 -0
- package/README.md +149 -107
- package/docs/limitations.md +58 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +102 -0
- package/package.json +9 -5
- package/src/cli-init.js +205 -0
- package/src/cli.js +159 -168
- package/src/core/artifacts.js +112 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +63 -0
- package/src/core/audit/metadata.js +88 -0
- package/src/core/audit/sitemap.js +39 -0
- package/src/core/blocking.js +53 -1
- package/src/core/browser.js +109 -39
- package/src/core/build-origin.js +19 -0
- package/src/core/config.js +256 -6
- package/src/core/crawl.js +300 -0
- package/src/core/csp.js +127 -0
- package/src/core/css-url.js +34 -0
- package/src/core/destination.js +28 -3
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/init.js +187 -31
- package/src/core/inline-css.js +34 -13
- package/src/core/metadata-origin.js +11 -2
- package/src/core/normalize.js +59 -2
- package/src/core/output.js +32 -9
- package/src/core/page-content.js +13 -0
- package/src/core/page-meta.js +108 -0
- package/src/core/preload-links.js +120 -0
- package/src/core/preload-manifest.js +61 -7
- package/src/core/prune.js +65 -0
- package/src/core/render.js +147 -11
- package/src/core/report.js +108 -0
- package/src/core/resources.js +6 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +209 -0
- package/src/core/routes.js +9 -0
- package/src/core/run.js +186 -358
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +128 -20
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +103 -49
- package/src/core/write.js +363 -0
- package/src/head/index.js +2 -2
- package/src/head/manager.js +54 -13
- package/src/head/tags.js +71 -32
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { auditLinks, auditOrphans } from './links.js';
|
|
2
|
+
import { auditMetadata } from './metadata.js';
|
|
3
|
+
import { auditSitemap } from './sitemap.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Audits the written site and returns report-only findings. Errors are the things a
|
|
7
|
+
* static site should not ship (broken links, sitemap disagreement, duplicate titles);
|
|
8
|
+
* warnings cover metadata that is useful but legitimately optional.
|
|
9
|
+
*/
|
|
10
|
+
export async function auditRun({ report, config, outputDir }) {
|
|
11
|
+
const errors = [];
|
|
12
|
+
const warnings = [];
|
|
13
|
+
const add = (severity, rule, message, details = {}) => {
|
|
14
|
+
const finding = { rule, severity, message, ...details };
|
|
15
|
+
if (severity === 'error') errors.push(finding);
|
|
16
|
+
else warnings.push(finding);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const pages = report.routeDetails.filter((detail) => detail.file?.endsWith('.html'));
|
|
20
|
+
auditRouteHealth({ report, add });
|
|
21
|
+
auditMetadata({ pages, config, add });
|
|
22
|
+
auditSitemap({ pages, config, report, add });
|
|
23
|
+
const linkAudit = await auditLinks({ report, config, outputDir, add });
|
|
24
|
+
auditOrphans({ pages, report, add });
|
|
25
|
+
auditCspHygiene({ pages, config, report, add });
|
|
26
|
+
removeDuplicateFindings({ errors, warnings });
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
errors,
|
|
30
|
+
warnings,
|
|
31
|
+
linksChecked: linkAudit.checked,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Mirrors route-level failures into the audit so failOnAuditError covers route health
|
|
36
|
+
// without requiring callers to inspect a second collection.
|
|
37
|
+
function auditRouteHealth({ report, add }) {
|
|
38
|
+
for (const error of report.errors ?? []) {
|
|
39
|
+
add('error', 'route-error', error.message, { route: error.route ?? undefined });
|
|
40
|
+
}
|
|
41
|
+
if (report.truncated) {
|
|
42
|
+
add(
|
|
43
|
+
'error',
|
|
44
|
+
'route-cap',
|
|
45
|
+
`maxRoutes (${report.truncated.limit}) stopped the crawl after ${report.truncated.routes} route(s)`,
|
|
46
|
+
{ droppedRoutes: report.truncated.droppedRoutes ?? [] },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
for (const route of report.emptyPages ?? []) {
|
|
50
|
+
add('error', 'empty-page', 'rendered an empty page', { route });
|
|
51
|
+
}
|
|
52
|
+
for (const group of report.duplicatePages ?? []) {
|
|
53
|
+
for (const route of group.routes) {
|
|
54
|
+
const others = group.routes.filter((other) => other !== route).join(', ');
|
|
55
|
+
add('error', 'duplicate-output', `identical output to ${others}`, { route });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Inline style attributes cannot be covered by a CSP hash, and browser console errors
|
|
61
|
+
// reveal assets the generated policy blocked.
|
|
62
|
+
function auditCspHygiene({ pages, config, report, add }) {
|
|
63
|
+
if (config.csp !== 'hash') return;
|
|
64
|
+
for (const route of report.cspSkipped ?? []) {
|
|
65
|
+
add(
|
|
66
|
+
'warning',
|
|
67
|
+
'existing-csp-meta',
|
|
68
|
+
`${route} already has a CSP meta, so no hashes were injected`,
|
|
69
|
+
{ route },
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const violations = new Map();
|
|
73
|
+
const collect = (route, message) => {
|
|
74
|
+
if (!/content security policy|violates the following/i.test(message)) return;
|
|
75
|
+
const messages = violations.get(route) ?? new Set();
|
|
76
|
+
messages.add(message);
|
|
77
|
+
violations.set(route, messages);
|
|
78
|
+
};
|
|
79
|
+
for (const { route, message } of report.consoleErrors ?? []) collect(route, message);
|
|
80
|
+
for (const page of pages) {
|
|
81
|
+
for (const message of page.consoleErrors ?? []) collect(page.route, message);
|
|
82
|
+
}
|
|
83
|
+
for (const [route, messages] of violations) {
|
|
84
|
+
add(
|
|
85
|
+
'warning',
|
|
86
|
+
'csp-console-violation',
|
|
87
|
+
`${route} logged ${messages.size} Content-Security-Policy violation(s)`,
|
|
88
|
+
{ route },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
for (const page of pages) {
|
|
92
|
+
if (page.meta?.inlineStyleAttributes > 0) {
|
|
93
|
+
add(
|
|
94
|
+
'warning',
|
|
95
|
+
'inline-style-attributes',
|
|
96
|
+
`${page.route} has ${page.meta.inlineStyleAttributes} inline style attribute(s); CSP hashes cannot cover them`,
|
|
97
|
+
{ route: page.route },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A finding can be produced by more than one check; keep the first copy.
|
|
104
|
+
function removeDuplicateFindings({ errors, warnings }) {
|
|
105
|
+
for (const list of [errors, warnings]) {
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
for (let index = list.length - 1; index >= 0; index -= 1) {
|
|
108
|
+
const finding = list[index];
|
|
109
|
+
const key = `${finding.rule}|${finding.route ?? ''}|${finding.message}`;
|
|
110
|
+
if (seen.has(key)) list.splice(index, 1);
|
|
111
|
+
else seen.add(key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { routeToFile } from '../output.js';
|
|
4
|
+
import { matchesPattern, normaliseRoute } from '../routes.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Audits every internal link the rendered pages emitted and warns about indexable pages
|
|
8
|
+
* no other page links to.
|
|
9
|
+
*/
|
|
10
|
+
export async function auditLinks({ report, config, outputDir, add }) {
|
|
11
|
+
const routes = new Set(report.routeDetails.map((detail) => detail.route));
|
|
12
|
+
const files = new Set(report.files.map((file) => file.file).filter(Boolean));
|
|
13
|
+
const cache = new Map();
|
|
14
|
+
const seenPairs = new Set();
|
|
15
|
+
let checkedLinks = 0;
|
|
16
|
+
|
|
17
|
+
const exists = async (target) => {
|
|
18
|
+
if (cache.has(target)) return cache.get(target);
|
|
19
|
+
const result = await fs
|
|
20
|
+
.stat(path.join(outputDir, target))
|
|
21
|
+
.then((stats) => stats.isFile())
|
|
22
|
+
.catch(() => false);
|
|
23
|
+
cache.set(target, result);
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
for (const link of report.links) {
|
|
28
|
+
const key = `${link.from}|${link.to}`;
|
|
29
|
+
if (seenPairs.has(key)) continue;
|
|
30
|
+
seenPairs.add(key);
|
|
31
|
+
if (config.auditIgnoreLinks.some((pattern) => matchesPattern(link.to, pattern))) continue;
|
|
32
|
+
checkedLinks += 1;
|
|
33
|
+
const route = normaliseRoute(link.to);
|
|
34
|
+
if (routes.has(route)) continue;
|
|
35
|
+
const file = routeToFile(route, config);
|
|
36
|
+
if (files.has(file)) continue;
|
|
37
|
+
const asset = route.startsWith('/') ? route.slice(1) : route;
|
|
38
|
+
if (asset && (await exists(asset))) continue;
|
|
39
|
+
add('error', 'broken-internal-link', `${link.from} links to missing ${link.to}`, {
|
|
40
|
+
from: link.from,
|
|
41
|
+
to: link.to,
|
|
42
|
+
reason: 'missing',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return { checked: checkedLinks };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Warns about indexable pages no rendered page links to. A link from a page outside the
|
|
50
|
+
* include list is legitimately absent, so this stays a warning.
|
|
51
|
+
*/
|
|
52
|
+
export function auditOrphans({ pages, report, add }) {
|
|
53
|
+
const linked = new Set();
|
|
54
|
+
for (const link of report.links) linked.add(normaliseRoute(link.to));
|
|
55
|
+
for (const page of pages) {
|
|
56
|
+
if (page.route === '/' || page.notFound || page.noindex) continue;
|
|
57
|
+
if (!linked.has(page.route)) {
|
|
58
|
+
add('warning', 'orphan-page', `${page.route} is not linked from another page`, {
|
|
59
|
+
route: page.route,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { toPublicPath } from '../routes.js';
|
|
2
|
+
import { pagePath } from '../url-policy.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks the metadata a crawler reads on every written page: titles, descriptions,
|
|
6
|
+
* canonicals, headings, build-origin leakage and duplicate values across routes.
|
|
7
|
+
*/
|
|
8
|
+
export function auditMetadata({ pages, config, add }) {
|
|
9
|
+
const titles = new Map();
|
|
10
|
+
const descriptions = new Map();
|
|
11
|
+
for (const page of pages) {
|
|
12
|
+
const meta = page.meta ?? {};
|
|
13
|
+
if (!meta.title) {
|
|
14
|
+
add('warning', 'missing-title', `no <title> on ${page.route}`, { route: page.route });
|
|
15
|
+
}
|
|
16
|
+
if (!meta.description) {
|
|
17
|
+
add('warning', 'missing-description', `no meta description on ${page.route}`, {
|
|
18
|
+
route: page.route,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (!meta.canonical) {
|
|
22
|
+
add('warning', 'missing-canonical', `no canonical link on ${page.route}`, {
|
|
23
|
+
route: page.route,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (meta.titleCount > 1) {
|
|
27
|
+
add('error', 'multiple-titles', `${page.route} contains more than one <title>`, {
|
|
28
|
+
route: page.route,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (meta.h1Count !== 1) {
|
|
32
|
+
add('warning', 'heading-count', `${page.route} has ${meta.h1Count} <h1> element(s)`, {
|
|
33
|
+
route: page.route,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if ((page.buildOriginUrls ?? []).length > 0) {
|
|
37
|
+
add(
|
|
38
|
+
'warning',
|
|
39
|
+
'build-origin-metadata',
|
|
40
|
+
`${page.route} still points at the build server: ${page.buildOriginUrls.join(', ')}`,
|
|
41
|
+
{ route: page.route },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (meta.title) titles.set(meta.title, [...(titles.get(meta.title) ?? []), page.route]);
|
|
45
|
+
if (meta.description) {
|
|
46
|
+
descriptions.set(meta.description, [
|
|
47
|
+
...(descriptions.get(meta.description) ?? []),
|
|
48
|
+
page.route,
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
if (config.metadata?.siteUrl && meta.canonical) {
|
|
52
|
+
const expected = expectedPageUrl(page.route, config);
|
|
53
|
+
if (expected && meta.canonical !== expected) {
|
|
54
|
+
add(
|
|
55
|
+
'warning',
|
|
56
|
+
'canonical-mismatch',
|
|
57
|
+
`${page.route} canonical is ${meta.canonical}, expected ${expected}`,
|
|
58
|
+
{ route: page.route },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const [title, routes] of titles) {
|
|
64
|
+
if (routes.length < 2) continue;
|
|
65
|
+
add('error', 'duplicate-title', `routes share the title "${title}": ${routes.join(', ')}`, {
|
|
66
|
+
routes,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
for (const [description, routes] of descriptions) {
|
|
70
|
+
if (routes.length < 2) continue;
|
|
71
|
+
add(
|
|
72
|
+
'warning',
|
|
73
|
+
'duplicate-description',
|
|
74
|
+
`routes share the meta description "${description}": ${routes.join(', ')}`,
|
|
75
|
+
{ routes },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Resolves the public URL a page should canonicalise and be listed at.
|
|
81
|
+
export function expectedPageUrl(route, config) {
|
|
82
|
+
if (!config.metadata?.siteUrl) return null;
|
|
83
|
+
const path = pagePath(route, {
|
|
84
|
+
flatOutput: config.flatOutput,
|
|
85
|
+
trailingSlash: config.metadata.trailingSlash,
|
|
86
|
+
});
|
|
87
|
+
return `${config.metadata.siteUrl.replace(/\/$/, '')}${toPublicPath(path, config.base)}`;
|
|
88
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expectedPageUrl } from './metadata.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ensures the sitemap and the indexable pages describe the same set of URLs. A page
|
|
5
|
+
* missing from the sitemap, a loc with no page, or a noindex page listed are all errors.
|
|
6
|
+
*/
|
|
7
|
+
export function auditSitemap({ pages, config, report, add }) {
|
|
8
|
+
if (!config.sitemap) return;
|
|
9
|
+
const indexable = new Map();
|
|
10
|
+
const noindex = new Map();
|
|
11
|
+
for (const page of pages) {
|
|
12
|
+
const loc = expectedPageUrl(page.route, config);
|
|
13
|
+
if (!loc) continue;
|
|
14
|
+
if (page.noindex || page.notFound) {
|
|
15
|
+
noindex.set(loc, page.route);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
indexable.set(loc, page.route);
|
|
19
|
+
}
|
|
20
|
+
const locs = new Set(report.sitemap?.locs ?? []);
|
|
21
|
+
for (const [loc, route] of indexable) {
|
|
22
|
+
if (!locs.has(loc)) {
|
|
23
|
+
add('error', 'sitemap-missing-page', `${route} is missing from the sitemap`, { route });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const loc of locs) {
|
|
27
|
+
if (noindex.has(loc)) {
|
|
28
|
+
add('error', 'sitemap-noindex-page', `sitemap lists noindex ${noindex.get(loc)}`, {
|
|
29
|
+
route: noindex.get(loc),
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!indexable.has(loc)) {
|
|
34
|
+
add('error', 'sitemap-unknown-page', `sitemap lists ${loc} with no indexable page`, {
|
|
35
|
+
to: loc,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/core/blocking.js
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports whether a host is on the allowlist. Entries may be a hostname, a hostname
|
|
3
|
+
* with a port, a leading-dot or wildcard suffix, or a full URL; matching is
|
|
4
|
+
* case-insensitive.
|
|
5
|
+
*/
|
|
6
|
+
export function isHostAllowed(hostname, port, allowedHosts) {
|
|
7
|
+
const host = hostname.toLowerCase();
|
|
8
|
+
const withPort = port ? `${host}:${port}` : host;
|
|
9
|
+
for (const raw of allowedHosts) {
|
|
10
|
+
const entry = String(raw).trim().toLowerCase();
|
|
11
|
+
if (entry.length === 0) continue;
|
|
12
|
+
if (entry.includes('://')) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(entry);
|
|
15
|
+
if (parsed.hostname.toLowerCase() === host && (!parsed.port || parsed.port === port)) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
// A malformed entry is simply not a match.
|
|
20
|
+
}
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (entry === withPort || entry === host) return true;
|
|
24
|
+
if (entry.startsWith('*.') && host.endsWith(entry.slice(1)) && host !== entry.slice(2)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (entry.startsWith('.') && host.endsWith(entry) && host !== entry.slice(1)) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
1
34
|
/**
|
|
2
35
|
* Aborts requests to third-party origins so analytics, ads and external widgets
|
|
3
36
|
* cannot mutate the DOM mid-capture or make output non-deterministic. Used by both
|
|
@@ -15,11 +48,30 @@ export async function blockThirdPartyRequests(page, origin, allowedHosts) {
|
|
|
15
48
|
return allow(route);
|
|
16
49
|
}
|
|
17
50
|
if (parsed.origin === origin) return allow(route);
|
|
18
|
-
if (
|
|
51
|
+
if (isHostAllowed(parsed.hostname, parsed.port, allowed)) return allow(route);
|
|
19
52
|
// A route can already be handled or cancelled, and that rejection is not this hook's to
|
|
20
53
|
// report, so it is swallowed rather than surfacing as an unhandled rejection.
|
|
21
54
|
return route.abort().catch(() => {});
|
|
22
55
|
});
|
|
56
|
+
|
|
57
|
+
// page.route does not see WebSockets; route them separately when the Playwright
|
|
58
|
+
// version provides the hook.
|
|
59
|
+
if (typeof page.routeWebSocket === 'function') {
|
|
60
|
+
await page.routeWebSocket('**/*', (socket) => {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = new URL(socket.url());
|
|
64
|
+
} catch {
|
|
65
|
+
socket.close();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (parsed.origin === origin || isHostAllowed(parsed.hostname, parsed.port, allowed)) {
|
|
69
|
+
socket.connectToServer();
|
|
70
|
+
} else {
|
|
71
|
+
socket.close();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
23
75
|
}
|
|
24
76
|
|
|
25
77
|
// Continues a request, ignoring the rejection that follows an already-handled route.
|
package/src/core/browser.js
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import * as browsers from '@puppeteer/browsers';
|
|
5
4
|
import { chromium } from 'playwright-core';
|
|
6
5
|
import { isExecutablePath } from './config.js';
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Lists the resolution steps for a browser option. Explicit choices only ever try the
|
|
9
|
+
* browser they name, so a machine without Chrome cannot silently prerender in Edge;
|
|
10
|
+
* `auto` is the only choice that falls through the machine, the Playwright cache, a
|
|
11
|
+
* cached Chrome for Testing, and finally a download.
|
|
12
|
+
*/
|
|
13
|
+
export function browserSteps(browser) {
|
|
14
|
+
if (browser === 'auto') return ['chrome', 'msedge', 'playwright', 'cache', 'download'];
|
|
15
|
+
if (browser === 'chromium') return ['playwright'];
|
|
16
|
+
if (browser === 'chrome' || browser === 'msedge') return [browser];
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves a usable Chromium-compatible browser. Explicit channel choices fail rather
|
|
22
|
+
* than falling back, while `auto` prefers the machine's browser, then any cached
|
|
23
|
+
* download, then a one-time Chrome for Testing install.
|
|
12
24
|
*/
|
|
13
25
|
export async function launchBrowser(config, log) {
|
|
14
26
|
if (isExecutablePath(config.browser)) {
|
|
@@ -19,40 +31,54 @@ export async function launchBrowser(config, log) {
|
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
const attempts = [];
|
|
22
|
-
const
|
|
23
|
-
config.browser === 'auto'
|
|
24
|
-
? ['chrome', 'msedge']
|
|
25
|
-
: config.browser === 'chromium'
|
|
26
|
-
? []
|
|
27
|
-
: [config.browser];
|
|
28
|
-
|
|
29
|
-
for (const channel of channels) {
|
|
34
|
+
for (const step of browserSteps(config.browser)) {
|
|
30
35
|
try {
|
|
31
|
-
const
|
|
32
|
-
|
|
36
|
+
const launched = await runStep(step, config, log);
|
|
37
|
+
if (launched) return launched;
|
|
33
38
|
} catch (error) {
|
|
34
|
-
attempts.push(`${
|
|
39
|
+
attempts.push(`${step}: ${firstLine(error.message)}`);
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
try {
|
|
39
|
-
const browser = await launch(config, {});
|
|
40
|
-
return { browser, source: 'playwright cache' };
|
|
41
|
-
} catch (error) {
|
|
42
|
-
attempts.push(`playwright cache: ${firstLine(error.message)}`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
43
|
if (!config.browserDownload) {
|
|
46
44
|
throw new Error(
|
|
47
45
|
`No usable browser found and browserDownload is disabled. Tried:\n ${attempts.join('\n ')}`,
|
|
48
46
|
);
|
|
49
47
|
}
|
|
48
|
+
if (config.browser === 'chromium') {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`No Playwright-managed Chromium is installed. Run "npx playwright install chromium", or set browser to auto or chrome. Tried:\n ${attempts.join('\n ')}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`No usable browser found. Tried:\n ${attempts.join('\n ')}`);
|
|
54
|
+
}
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
// Runs one browser resolution step and returns the launched browser, or null when the
|
|
57
|
+
// step has nothing to offer on this machine.
|
|
58
|
+
async function runStep(step, config, log) {
|
|
59
|
+
if (step === 'chrome' || step === 'msedge') {
|
|
60
|
+
return { browser: await launch(config, { channel: step }), source: `system ${step}` };
|
|
61
|
+
}
|
|
62
|
+
if (step === 'playwright') {
|
|
63
|
+
return { browser: await launch(config, {}), source: 'playwright cache' };
|
|
64
|
+
}
|
|
65
|
+
if (step === 'cache') {
|
|
66
|
+
const cached = await findCachedChrome(config);
|
|
67
|
+
if (!cached) return null;
|
|
68
|
+
return {
|
|
69
|
+
browser: await launch(config, { executablePath: cached.executablePath }),
|
|
70
|
+
source: `cached Chrome for Testing ${cached.buildId}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (step === 'download') {
|
|
74
|
+
if (!config.browserDownload) return null;
|
|
75
|
+
const executablePath = await downloadChrome(config, log);
|
|
76
|
+
return {
|
|
77
|
+
browser: await launch(config, { executablePath }),
|
|
78
|
+
source: `downloaded ${executablePath}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
56
82
|
}
|
|
57
83
|
|
|
58
84
|
// Launches Chromium with the configured headless mode, extra args and target.
|
|
@@ -70,20 +96,64 @@ function firstLine(message) {
|
|
|
70
96
|
}
|
|
71
97
|
|
|
72
98
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
* Chooses a cached Chrome for Testing install. Kept pure so the offline/version-pinned
|
|
100
|
+
* selection can be tested without touching the machine's browser cache.
|
|
101
|
+
*/
|
|
102
|
+
export function selectCachedChrome(
|
|
103
|
+
installed,
|
|
104
|
+
{ browser = 'chrome', platform, buildId = null, executableExists = () => true },
|
|
105
|
+
) {
|
|
106
|
+
return (
|
|
107
|
+
installed.find(
|
|
108
|
+
(entry) =>
|
|
109
|
+
entry.browser === browser &&
|
|
110
|
+
entry.platform === platform &&
|
|
111
|
+
(!buildId || entry.buildId === buildId) &&
|
|
112
|
+
executableExists(entry.executablePath),
|
|
113
|
+
) ?? null
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Formats download progress for the debug log; a missing content length reports 0%.
|
|
118
|
+
export function downloadPercent(downloaded, total) {
|
|
119
|
+
return total > 0 ? Math.round((downloaded / total) * 100) : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Finds a Chrome for Testing build already present in the tool's cache, so a rebuild
|
|
124
|
+
* never needs the version feed or a download when a browser is available offline.
|
|
125
|
+
* Honours browserDownloadVersion when one is configured.
|
|
126
|
+
*/
|
|
127
|
+
async function findCachedChrome(config) {
|
|
128
|
+
const browsers = await import('@puppeteer/browsers');
|
|
129
|
+
const platform = browsers.detectBrowserPlatform();
|
|
130
|
+
if (!platform) return null;
|
|
131
|
+
const installed = browsers.getInstalledBrowsers({ cacheDir: browserCacheDir() });
|
|
132
|
+
return selectCachedChrome(installed, {
|
|
133
|
+
browser: browsers.Browser.CHROME,
|
|
134
|
+
platform,
|
|
135
|
+
buildId: config.browserDownloadVersion,
|
|
136
|
+
executableExists: fs.existsSync,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Downloads Chrome for Testing into the tool's own cache directory. The archive comes
|
|
142
|
+
* from Google's version-pinned HTTPS bucket via @puppeteer/browsers, which verifies it
|
|
143
|
+
* against browserDownloadHash when one is configured and deletes it on a mismatch.
|
|
144
|
+
* Without a configured hash the download is trusted on the strength of HTTPS alone,
|
|
145
|
+
* which is logged as a warning.
|
|
79
146
|
*/
|
|
80
147
|
async function downloadChrome(config, log) {
|
|
148
|
+
const browsers = await import('@puppeteer/browsers');
|
|
149
|
+
const platform = browsers.detectBrowserPlatform();
|
|
150
|
+
if (!platform) {
|
|
151
|
+
throw new Error(`Cannot download a browser for ${os.platform()} (${os.arch()})`);
|
|
152
|
+
}
|
|
81
153
|
const cacheDir = browserCacheDir();
|
|
82
|
-
const buildId =
|
|
83
|
-
|
|
84
|
-
browsers.
|
|
85
|
-
'stable',
|
|
86
|
-
);
|
|
154
|
+
const buildId =
|
|
155
|
+
config.browserDownloadVersion ??
|
|
156
|
+
(await browsers.resolveBuildId(browsers.Browser.CHROME, platform, 'stable'));
|
|
87
157
|
if (config.browserDownloadHash) {
|
|
88
158
|
log.info(
|
|
89
159
|
`Downloading Chrome for Testing ${buildId}, verifying SHA-256 against browserDownloadHash`,
|
|
@@ -99,7 +169,7 @@ async function downloadChrome(config, log) {
|
|
|
99
169
|
cacheDir,
|
|
100
170
|
expectedHash: config.browserDownloadHash ?? undefined,
|
|
101
171
|
downloadProgressCallback: (downloaded, total) =>
|
|
102
|
-
log.debug(` downloaded ${
|
|
172
|
+
log.debug(` downloaded ${downloadPercent(downloaded, total)}%`),
|
|
103
173
|
});
|
|
104
174
|
if (!fs.existsSync(installed.executablePath)) {
|
|
105
175
|
throw new Error(`Chrome download reported success but ${installed.executablePath} is missing`);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { parse } from 'parse5';
|
|
2
|
+
import { collectBuildOrigins, isMetadataUrlNode, metadataUrlValue } from './metadata-origin.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Collects build-server origins from the metadata URLs in a serialized document.
|
|
6
|
+
* Only canonical and URL-bearing social tags are inspected, so a loopback API URL in
|
|
7
|
+
* application state does not trip the optional build-origin gate.
|
|
8
|
+
*/
|
|
9
|
+
export function findBuildOriginUrls(html) {
|
|
10
|
+
const origins = new Set();
|
|
11
|
+
const walk = (node) => {
|
|
12
|
+
if (isMetadataUrlNode(node)) {
|
|
13
|
+
for (const origin of collectBuildOrigins(metadataUrlValue(node))) origins.add(origin);
|
|
14
|
+
}
|
|
15
|
+
for (const child of node.childNodes ?? []) walk(child);
|
|
16
|
+
};
|
|
17
|
+
walk(parse(html));
|
|
18
|
+
return [...origins].slice(0, 3);
|
|
19
|
+
}
|