samey-cli 0.0.0-stage → 0.1.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 +43 -0
- package/LICENSE +21 -0
- package/README.md +224 -2
- package/dist/ai/local-model.d.ts +26 -0
- package/dist/ai/local-model.js +91 -0
- package/dist/audit.d.ts +18 -0
- package/dist/audit.js +250 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +166 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.js +40 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/render/crawl.d.ts +34 -0
- package/dist/render/crawl.js +337 -0
- package/dist/render/extract.d.ts +8 -0
- package/dist/render/extract.js +441 -0
- package/dist/render/interact.d.ts +8 -0
- package/dist/render/interact.js +76 -0
- package/dist/render/network.d.ts +33 -0
- package/dist/render/network.js +92 -0
- package/dist/render/robots.d.ts +8 -0
- package/dist/render/robots.js +61 -0
- package/dist/render/server.d.ts +15 -0
- package/dist/render/server.js +174 -0
- package/dist/report/html.d.ts +2 -0
- package/dist/report/html.js +368 -0
- package/dist/report/prompt.d.ts +12 -0
- package/dist/report/prompt.js +62 -0
- package/dist/report/terminal.d.ts +6 -0
- package/dist/report/terminal.js +73 -0
- package/dist/rules/content.d.ts +8 -0
- package/dist/rules/content.js +227 -0
- package/dist/rules/functional.d.ts +17 -0
- package/dist/rules/functional.js +467 -0
- package/dist/rules/helpers.d.ts +11 -0
- package/dist/rules/helpers.js +29 -0
- package/dist/rules/index.d.ts +13 -0
- package/dist/rules/index.js +100 -0
- package/dist/rules/normalize.d.ts +18 -0
- package/dist/rules/normalize.js +114 -0
- package/dist/rules/static.d.ts +9 -0
- package/dist/rules/static.js +314 -0
- package/dist/rules/types.d.ts +39 -0
- package/dist/rules/types.js +16 -0
- package/dist/rules/visual.d.ts +15 -0
- package/dist/rules/visual.js +463 -0
- package/dist/score.d.ts +31 -0
- package/dist/score.js +66 -0
- package/dist/security/fetch.d.ts +26 -0
- package/dist/security/fetch.js +86 -0
- package/dist/security/net-policy.d.ts +46 -0
- package/dist/security/net-policy.js +178 -0
- package/dist/static/scan.d.ts +15 -0
- package/dist/static/scan.js +191 -0
- package/dist/types.d.ts +316 -0
- package/dist/types.js +1 -0
- package/dist/util/color.d.ts +25 -0
- package/dist/util/color.js +180 -0
- package/dist/util/text.d.ts +22 -0
- package/dist/util/text.js +106 -0
- package/package.json +69 -4
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { guardedFetch, guardedRequest } from '../security/fetch.js';
|
|
4
|
+
import { BlockedError, checkUrlSyntax, displayUrl } from '../security/net-policy.js';
|
|
5
|
+
import { extractViewportFacts } from './extract.js';
|
|
6
|
+
import { testInteractions } from './interact.js';
|
|
7
|
+
import { DEFAULT_LIMITS, guardContext, newNetworkLog } from './network.js';
|
|
8
|
+
import { isAllowed, parseRobots } from './robots.js';
|
|
9
|
+
export const VIEWPORTS = {
|
|
10
|
+
desktop: { width: 1440, height: 900, isMobile: false },
|
|
11
|
+
mobile: { width: 390, height: 844, isMobile: true },
|
|
12
|
+
};
|
|
13
|
+
const MAX_SHOT_HEIGHT = { desktop: 5000, mobile: 6000 };
|
|
14
|
+
const SKIP_EXT = /\.(?:pdf|zip|gz|tar|png|jpe?g|gif|webp|avif|svg|ico|mp4|webm|mp3|wav|woff2?|ttf|css|js|mjs|json|xml|txt|csv|docx?|xlsx?|pptx?)$/i;
|
|
15
|
+
export class BrowserMissingError extends Error {
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* In remote mode every request is served from Node by the network guard. Anything Chromium
|
|
19
|
+
* tries to send on its own (preconnects, WebRTC) is pointed at a closed local port so it
|
|
20
|
+
* cannot reach the network.
|
|
21
|
+
*/
|
|
22
|
+
export async function launchBrowser(policy) {
|
|
23
|
+
let chromium;
|
|
24
|
+
try {
|
|
25
|
+
({ chromium } = await import('playwright'));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new BrowserMissingError('Playwright is missing from the samey install. Reinstall samey, or use --static-only.');
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return await chromium.launch({
|
|
32
|
+
headless: true,
|
|
33
|
+
args: ['--force-webrtc-ip-handling-policy=disable_non_proxied_udp', '--dns-prefetch-disable', '--disable-features=NetworkPrediction'],
|
|
34
|
+
proxy: policy.allowLoopback ? undefined : { server: 'http://127.0.0.1:9' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
const msg = err.message;
|
|
39
|
+
if (/Executable doesn't exist|browserType.launch/i.test(msg)) {
|
|
40
|
+
throw new BrowserMissingError('Chromium is not installed yet. Run `npx samey-cli setup` once (about 150 MB), or use --static-only to skip rendered checks.');
|
|
41
|
+
}
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Canonical key for a URL: origin + path without trailing slash, no query or hash. */
|
|
46
|
+
export function urlKey(u) {
|
|
47
|
+
const p = u.pathname.replace(/\/index\.html?$/, '/').replace(/\/+$/, '') || '/';
|
|
48
|
+
return u.origin + p;
|
|
49
|
+
}
|
|
50
|
+
export function normalizeLink(href, base) {
|
|
51
|
+
if (!href || /^(?:mailto|tel|javascript|data|blob|sms|ftp):/i.test(href) || href.startsWith('#'))
|
|
52
|
+
return null;
|
|
53
|
+
try {
|
|
54
|
+
const u = new URL(href, base);
|
|
55
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
56
|
+
return null;
|
|
57
|
+
u.hash = '';
|
|
58
|
+
u.search = '';
|
|
59
|
+
return u;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function slug(p) {
|
|
66
|
+
return (p.replace(/^\/+|\/+$/g, '').replace(/[^a-z0-9]+/gi, '-').toLowerCase() || 'home').slice(0, 60);
|
|
67
|
+
}
|
|
68
|
+
async function settle(page, timeoutMs) {
|
|
69
|
+
await page.waitForLoadState('networkidle', { timeout: Math.min(timeoutMs, 5000) }).catch(() => undefined);
|
|
70
|
+
// Scroll through once so lazy-loaded content and intersection-triggered animations resolve.
|
|
71
|
+
await page
|
|
72
|
+
.evaluate(async () => {
|
|
73
|
+
const step = window.innerHeight;
|
|
74
|
+
const max = Math.min(document.documentElement.scrollHeight, step * 15);
|
|
75
|
+
for (let y = 0; y < max; y += step) {
|
|
76
|
+
window.scrollTo(0, y);
|
|
77
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
78
|
+
}
|
|
79
|
+
window.scrollTo(0, 0);
|
|
80
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
81
|
+
})
|
|
82
|
+
.catch(() => undefined);
|
|
83
|
+
}
|
|
84
|
+
async function capture(page, viewport, file) {
|
|
85
|
+
if (!file)
|
|
86
|
+
return undefined;
|
|
87
|
+
const { width } = VIEWPORTS[viewport];
|
|
88
|
+
const docHeight = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 900);
|
|
89
|
+
const height = Math.max(1, Math.min(docHeight, MAX_SHOT_HEIGHT[viewport]));
|
|
90
|
+
await page.screenshot({ path: file, type: 'jpeg', quality: 60, fullPage: true, clip: { x: 0, y: 0, width, height }, animations: 'disabled', timeout: 20_000 });
|
|
91
|
+
return height;
|
|
92
|
+
}
|
|
93
|
+
function attach(page, origin, sink, network) {
|
|
94
|
+
page.on('console', (msg) => {
|
|
95
|
+
if (msg.type() !== 'error' || sink.consoleErrors.length >= 30)
|
|
96
|
+
return;
|
|
97
|
+
// "Failed to load resource" for a request the guard blocked is not a site error.
|
|
98
|
+
if (network.blockedUrls.has(msg.location()?.url ?? ''))
|
|
99
|
+
return;
|
|
100
|
+
sink.consoleErrors.push(msg.text().slice(0, 300));
|
|
101
|
+
});
|
|
102
|
+
page.on('pageerror', (err) => {
|
|
103
|
+
if (sink.consoleErrors.length < 30)
|
|
104
|
+
sink.consoleErrors.push(`Uncaught: ${err.message.slice(0, 280)}`);
|
|
105
|
+
});
|
|
106
|
+
page.on('response', (res) => {
|
|
107
|
+
const status = res.status();
|
|
108
|
+
if (status < 400)
|
|
109
|
+
return;
|
|
110
|
+
const req = res.request();
|
|
111
|
+
const sameOrigin = res.url().startsWith(origin);
|
|
112
|
+
if ((sameOrigin || ['document', 'script', 'stylesheet'].includes(req.resourceType())) && sink.failedRequests.length < 40) {
|
|
113
|
+
sink.failedRequests.push({ url: res.url(), status, resourceType: req.resourceType() });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
page.on('requestfailed', (req) => {
|
|
117
|
+
if (network.blockedUrls.has(req.url()))
|
|
118
|
+
return;
|
|
119
|
+
const failure = req.failure()?.errorText ?? 'failed';
|
|
120
|
+
if (/ERR_ABORTED|cancelled/i.test(failure))
|
|
121
|
+
return;
|
|
122
|
+
if (sink.failedRequests.length < 40)
|
|
123
|
+
sink.failedRequests.push({ url: req.url(), failure, resourceType: req.resourceType() });
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* page.goto that follows redirects the network guard intercepted. Each target is checked
|
|
128
|
+
* against the policy before navigating; the guard validates its DNS on the next request.
|
|
129
|
+
*/
|
|
130
|
+
async function gotoGuarded(page, url, opts, network) {
|
|
131
|
+
let current = url;
|
|
132
|
+
for (let hop = 0; hop <= 5; hop++) {
|
|
133
|
+
network.navRedirects.delete(current);
|
|
134
|
+
try {
|
|
135
|
+
return await page.goto(current, { waitUntil: 'load', timeout: opts.navTimeoutMs });
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
const next = network.navRedirects.get(current);
|
|
139
|
+
if (!next)
|
|
140
|
+
throw err;
|
|
141
|
+
network.navRedirects.delete(current);
|
|
142
|
+
try {
|
|
143
|
+
checkUrlSyntax(next, opts.policy);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
if (network.blocked.length < 200)
|
|
147
|
+
network.blocked.push({ url: displayUrl(next), reason: e.message });
|
|
148
|
+
throw new Error(`redirect to ${displayUrl(next)} was blocked: ${e.message}`);
|
|
149
|
+
}
|
|
150
|
+
current = next;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`more than 5 redirects starting at ${displayUrl(url)}`);
|
|
154
|
+
}
|
|
155
|
+
async function auditViewport(context, url, viewport, opts, sink, shotFile, origin, network) {
|
|
156
|
+
const page = await context.newPage();
|
|
157
|
+
page.setDefaultTimeout(opts.pageTimeoutMs);
|
|
158
|
+
attach(page, origin, sink, network);
|
|
159
|
+
let status = null;
|
|
160
|
+
let contentType;
|
|
161
|
+
try {
|
|
162
|
+
const res = await gotoGuarded(page, url, opts, network);
|
|
163
|
+
status = res?.status() ?? null;
|
|
164
|
+
contentType = res?.headers()['content-type'];
|
|
165
|
+
if (contentType && !/html/i.test(contentType))
|
|
166
|
+
return { status, finalUrl: page.url(), contentType, page };
|
|
167
|
+
await settle(page, opts.pageTimeoutMs);
|
|
168
|
+
const facts = await extractViewportFacts(page, viewport);
|
|
169
|
+
try {
|
|
170
|
+
const h = await capture(page, viewport, shotFile);
|
|
171
|
+
if (h && shotFile) {
|
|
172
|
+
facts.screenshot = path.basename(shotFile);
|
|
173
|
+
facts.screenshotHeight = h;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// A failed screenshot should not lose the extracted facts.
|
|
178
|
+
}
|
|
179
|
+
return { facts, status, finalUrl: page.url(), contentType, page };
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
return { status, error: err.message.split('\n')[0].slice(0, 300), finalUrl: page.url(), contentType, page };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
export async function crawl(browser, opts) {
|
|
186
|
+
if (opts.shotsDir)
|
|
187
|
+
mkdirSync(opts.shotsDir, { recursive: true });
|
|
188
|
+
const common = { reducedMotion: 'reduce', acceptDownloads: false, serviceWorkers: 'block' };
|
|
189
|
+
const desktop = await browser.newContext({ ...common, viewport: { width: VIEWPORTS.desktop.width, height: VIEWPORTS.desktop.height } });
|
|
190
|
+
const mobile = await browser.newContext({
|
|
191
|
+
...common,
|
|
192
|
+
viewport: { width: VIEWPORTS.mobile.width, height: VIEWPORTS.mobile.height },
|
|
193
|
+
isMobile: true,
|
|
194
|
+
hasTouch: true,
|
|
195
|
+
deviceScaleFactor: 1,
|
|
196
|
+
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 samey',
|
|
197
|
+
});
|
|
198
|
+
const limits = opts.limits ?? DEFAULT_LIMITS;
|
|
199
|
+
const network = newNetworkLog();
|
|
200
|
+
await guardContext(desktop, opts.policy, limits, network);
|
|
201
|
+
await guardContext(mobile, opts.policy, limits, network);
|
|
202
|
+
const fetchOpts = { policy: opts.policy, maxBytes: 2 * 1024 * 1024, timeoutMs: Math.min(opts.navTimeoutMs, 10_000), headers: { 'user-agent': 'samey' } };
|
|
203
|
+
// If the start URL redirects (example.com to www.example.com, http to https), crawl the site it lands on.
|
|
204
|
+
let startUrl = opts.startUrl;
|
|
205
|
+
for (let hop = 0; hop < 5; hop++) {
|
|
206
|
+
const r = await guardedRequest(startUrl, { ...fetchOpts, method: 'GET' }).catch((e) => e);
|
|
207
|
+
if (r instanceof BlockedError)
|
|
208
|
+
throw r;
|
|
209
|
+
if (r instanceof Error || r.status < 300 || r.status >= 400 || !r.headers['location'])
|
|
210
|
+
break;
|
|
211
|
+
startUrl = new URL(String(r.headers['location']), startUrl).href;
|
|
212
|
+
}
|
|
213
|
+
const origin = new URL(startUrl).origin;
|
|
214
|
+
const result = { baseUrl: startUrl, pages: [], skipped: [], brokenLinks: [], discovered: 0, limitHit: false, network };
|
|
215
|
+
let robots = { allow: [], disallow: [] };
|
|
216
|
+
if (opts.respectRobots) {
|
|
217
|
+
const res = await guardedFetch(origin + '/robots.txt', fetchOpts).catch(() => null);
|
|
218
|
+
if (res && res.status === 200)
|
|
219
|
+
robots = parseRobots(res.body.toString('utf8'));
|
|
220
|
+
}
|
|
221
|
+
const queue = [];
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
const referrer = new Map();
|
|
224
|
+
const enqueue = (u, from) => {
|
|
225
|
+
if (!u || u.origin !== origin || SKIP_EXT.test(u.pathname))
|
|
226
|
+
return;
|
|
227
|
+
const key = urlKey(u);
|
|
228
|
+
if (seen.has(key))
|
|
229
|
+
return;
|
|
230
|
+
seen.add(key);
|
|
231
|
+
if (from)
|
|
232
|
+
referrer.set(key, from);
|
|
233
|
+
queue.push(u);
|
|
234
|
+
};
|
|
235
|
+
enqueue(normalizeLink(startUrl, startUrl));
|
|
236
|
+
for (const s of opts.seeds)
|
|
237
|
+
enqueue(normalizeLink(s, startUrl), '(route hint)');
|
|
238
|
+
const sitemap = await guardedFetch(origin + '/sitemap.xml', fetchOpts).catch(() => null);
|
|
239
|
+
if (sitemap && sitemap.status === 200 && /xml/i.test(String(sitemap.headers['content-type'] ?? ''))) {
|
|
240
|
+
const xml = sitemap.body.toString('utf8');
|
|
241
|
+
for (const m of xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g))
|
|
242
|
+
enqueue(normalizeLink(m[1], startUrl), '(sitemap)');
|
|
243
|
+
}
|
|
244
|
+
const crawled = new Set();
|
|
245
|
+
while (queue.length && result.pages.length < opts.maxRoutes) {
|
|
246
|
+
const u = queue.shift();
|
|
247
|
+
const key = urlKey(u);
|
|
248
|
+
if (opts.respectRobots && !isAllowed(robots, u.pathname)) {
|
|
249
|
+
result.skipped.push({ url: u.href, reason: 'disallowed by robots.txt' });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
opts.onProgress?.(`Auditing ${u.pathname} (page ${result.pages.length + 1}, limit ${opts.maxRoutes})`);
|
|
253
|
+
const sink = { consoleErrors: [], failedRequests: [] };
|
|
254
|
+
const begin = Date.now();
|
|
255
|
+
const shot = (vp) => (opts.shotsDir ? path.join(opts.shotsDir, `${String(result.pages.length + 1).padStart(2, '0')}-${slug(u.pathname)}-${vp}.jpg`) : undefined);
|
|
256
|
+
const d = await auditViewport(desktop, u.href, 'desktop', opts, sink, shot('desktop'), origin, network);
|
|
257
|
+
if (d.contentType && !/html/i.test(d.contentType)) {
|
|
258
|
+
result.skipped.push({ url: u.href, reason: `not an HTML page (${d.contentType.split(';')[0]})` });
|
|
259
|
+
await d.page.close();
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const landed = new URL(d.finalUrl || u.href);
|
|
263
|
+
if (landed.origin !== origin && landed.protocol.startsWith('http')) {
|
|
264
|
+
result.skipped.push({ url: u.href, reason: `redirected to another site (${displayUrl(landed.href)})` });
|
|
265
|
+
await d.page.close();
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
// Record the page where navigation landed; a second link redirecting to it is not audited twice.
|
|
269
|
+
const landedKey = urlKey(landed);
|
|
270
|
+
if (landedKey !== key && crawled.has(landedKey)) {
|
|
271
|
+
result.skipped.push({ url: u.href, reason: `redirects to ${landed.pathname}, already audited` });
|
|
272
|
+
await d.page.close();
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
crawled.add(key);
|
|
276
|
+
crawled.add(landedKey);
|
|
277
|
+
seen.add(landedKey);
|
|
278
|
+
const pageUrl = landed.protocol.startsWith('http') ? landed : u;
|
|
279
|
+
const facts = {
|
|
280
|
+
url: pageUrl.href,
|
|
281
|
+
path: pageUrl.pathname,
|
|
282
|
+
status: d.status,
|
|
283
|
+
loadMs: 0,
|
|
284
|
+
error: d.error,
|
|
285
|
+
consoleErrors: sink.consoleErrors,
|
|
286
|
+
failedRequests: sink.failedRequests,
|
|
287
|
+
// Error pages are reported as broken links; their content is not audited as a real page.
|
|
288
|
+
desktop: (d.status ?? 200) < 400 ? d.facts : undefined,
|
|
289
|
+
interactions: [],
|
|
290
|
+
};
|
|
291
|
+
if (d.facts) {
|
|
292
|
+
for (const link of d.facts.links)
|
|
293
|
+
if (link.internal)
|
|
294
|
+
enqueue(normalizeLink(link.href, d.finalUrl || u.href), u.pathname);
|
|
295
|
+
if (opts.interact && (d.status ?? 200) < 400) {
|
|
296
|
+
facts.interactions = await testInteractions(d.page, d.facts.controls, 6, opts.pageTimeoutMs).catch(() => []);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
await d.page.close();
|
|
300
|
+
facts.loadMs = Date.now() - begin;
|
|
301
|
+
if (!d.error && (d.status ?? 200) < 400) {
|
|
302
|
+
const m = await auditViewport(mobile, u.href, 'mobile', opts, sink, shot('mobile'), origin, network);
|
|
303
|
+
facts.mobile = m.facts;
|
|
304
|
+
if (m.error && !facts.error)
|
|
305
|
+
facts.error = `mobile: ${m.error}`;
|
|
306
|
+
await m.page.close();
|
|
307
|
+
}
|
|
308
|
+
facts.consoleErrors = [...new Set(sink.consoleErrors)];
|
|
309
|
+
const seenReq = new Set();
|
|
310
|
+
facts.failedRequests = sink.failedRequests.filter((r) => !seenReq.has(r.url) && !!seenReq.add(r.url));
|
|
311
|
+
result.pages.push(facts);
|
|
312
|
+
}
|
|
313
|
+
result.limitHit = queue.length > 0;
|
|
314
|
+
result.discovered = seen.size;
|
|
315
|
+
for (const u of queue)
|
|
316
|
+
result.skipped.push({ url: u.href, reason: `route limit (${opts.maxRoutes}) reached` });
|
|
317
|
+
// Broken internal links: crawled pages that returned errors, plus a bounded status check of the rest.
|
|
318
|
+
for (const p of result.pages) {
|
|
319
|
+
if (p.status !== null && p.status >= 400) {
|
|
320
|
+
result.brokenLinks.push({ from: referrer.get(urlKey(new URL(p.url))) ?? '(start url)', url: p.url, status: p.status });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const unchecked = queue.slice(0, 40);
|
|
324
|
+
for (const u of unchecked) {
|
|
325
|
+
const res = await guardedFetch(u.href, fetchOpts).catch((e) => e);
|
|
326
|
+
if (res instanceof Error) {
|
|
327
|
+
// Too large is not broken; it just was not worth downloading for a status check.
|
|
328
|
+
if (!/byte limit/.test(res.message))
|
|
329
|
+
result.brokenLinks.push({ from: referrer.get(urlKey(u)) ?? '?', url: u.href, status: null, error: res.message.split('\n')[0] });
|
|
330
|
+
}
|
|
331
|
+
else if (res.status >= 400)
|
|
332
|
+
result.brokenLinks.push({ from: referrer.get(urlKey(u)) ?? '?', url: u.href, status: res.status });
|
|
333
|
+
}
|
|
334
|
+
await desktop.close();
|
|
335
|
+
await mobile.close();
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import type { ViewportFacts, ViewportName } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Runs `fn` inside the page. The function is shipped as source text so that bundler
|
|
5
|
+
* helpers (esbuild's `__name`, used by tsx and vitest) never leak into the browser.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runInPage<A, R>(page: Page, fn: (arg: A) => R, arg: A): Promise<R>;
|
|
8
|
+
export declare function extractViewportFacts(page: Page, viewport: ViewportName): Promise<ViewportFacts>;
|