shopify-audit 1.0.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/bin/shopify-audit.js +77 -0
- package/package.json +34 -0
- package/src/analyzers/lcp.js +32 -0
- package/src/analyzers/main-thread.js +11 -0
- package/src/analyzers/render-blocking.js +44 -0
- package/src/analyzers/third-party.js +74 -0
- package/src/browser.js +77 -0
- package/src/cli.js +71 -0
- package/src/collectors/injected-scripts.js +55 -0
- package/src/collectors/navigation.js +13 -0
- package/src/collectors/network.js +59 -0
- package/src/collectors/paint-and-tasks.js +106 -0
- package/src/collectors/scripts.js +22 -0
- package/src/collectors/stylesheets.js +13 -0
- package/src/discovery.js +71 -0
- package/src/findings.js +105 -0
- package/src/index.js +196 -0
- package/src/reporter.js +174 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from '../src/cli.js';
|
|
4
|
+
import { runAudit } from '../src/index.js';
|
|
5
|
+
|
|
6
|
+
const args = parseArgs(process.argv.slice(2));
|
|
7
|
+
|
|
8
|
+
if (args.error) {
|
|
9
|
+
console.error(`Error: ${args.error}`);
|
|
10
|
+
console.error('');
|
|
11
|
+
console.error('Usage: shopify-audit <url> [options]');
|
|
12
|
+
console.error('');
|
|
13
|
+
console.error('Options:');
|
|
14
|
+
console.error(' --mobile Emulate mobile device (default)');
|
|
15
|
+
console.error(' --desktop Emulate desktop device');
|
|
16
|
+
console.error(' --runs <n> Number of runs per page (default: 1)');
|
|
17
|
+
console.error(' --password <pw> Storefront password');
|
|
18
|
+
console.error(' --block-analytics Block known analytics scripts');
|
|
19
|
+
console.error(' --json <file> Write JSON report to file');
|
|
20
|
+
console.error(' --pdp-url <url> Override product page URL');
|
|
21
|
+
console.error(' --plp-url <url> Override collection page URL');
|
|
22
|
+
console.error(' --fail-on <rules> Exit 1 if conditions met (comma-separated)');
|
|
23
|
+
console.error(' Rules: render-blocking, tbt:<ms>, lcp:<ms>');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
runAudit(args)
|
|
28
|
+
.then((report) => {
|
|
29
|
+
if (args.failOn.length > 0) {
|
|
30
|
+
const failed = checkFailConditions(report, args.failOn);
|
|
31
|
+
if (failed.length > 0) {
|
|
32
|
+
console.error('');
|
|
33
|
+
console.error(' \x1b[31mFail conditions triggered:\x1b[0m');
|
|
34
|
+
for (const msg of failed) {
|
|
35
|
+
console.error(` • ${msg}`);
|
|
36
|
+
}
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
.catch((err) => {
|
|
42
|
+
console.error('Fatal error:', err.message);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function checkFailConditions(report, failOn) {
|
|
47
|
+
const failures = [];
|
|
48
|
+
|
|
49
|
+
for (const rule of failOn) {
|
|
50
|
+
if (rule === 'render-blocking') {
|
|
51
|
+
for (const page of report.pages) {
|
|
52
|
+
const count = page.scripts.filter((s) => s.renderBlocking).length;
|
|
53
|
+
if (count > 0) {
|
|
54
|
+
failures.push(`${page.pageType}: ${count} render-blocking script(s)`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} else if (rule.startsWith('tbt:')) {
|
|
58
|
+
const threshold = parseInt(rule.split(':')[1], 10);
|
|
59
|
+
if (isNaN(threshold)) continue;
|
|
60
|
+
for (const page of report.pages) {
|
|
61
|
+
if (page.metrics.tbt > threshold) {
|
|
62
|
+
failures.push(`${page.pageType}: TBT ${page.metrics.tbt}ms > ${threshold}ms`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} else if (rule.startsWith('lcp:')) {
|
|
66
|
+
const threshold = parseInt(rule.split(':')[1], 10);
|
|
67
|
+
if (isNaN(threshold)) continue;
|
|
68
|
+
for (const page of report.pages) {
|
|
69
|
+
if (page.metrics.lcp > threshold) {
|
|
70
|
+
failures.push(`${page.pageType}: LCP ${page.metrics.lcp}ms > ${threshold}ms`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return failures;
|
|
77
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shopify-audit",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Runtime performance audit tool for Shopify storefronts",
|
|
5
|
+
"bin": {
|
|
6
|
+
"shopify-audit": "./bin/shopify-audit.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node bin/shopify-audit.js",
|
|
12
|
+
"postinstall": "npx playwright install chromium"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.0.0"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"shopify",
|
|
23
|
+
"performance",
|
|
24
|
+
"audit",
|
|
25
|
+
"web-vitals",
|
|
26
|
+
"lcp",
|
|
27
|
+
"tbt",
|
|
28
|
+
"playwright"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"playwright": "^1.48.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function analyzeLcp(paintData, networkRequests) {
|
|
2
|
+
const result = {
|
|
3
|
+
lcpElement: paintData.lcpElement,
|
|
4
|
+
lcpTime: paintData.lcp,
|
|
5
|
+
lcpResource: null,
|
|
6
|
+
longTasksBeforeLcp: 0,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
if (!paintData.lcpElement) return result;
|
|
10
|
+
|
|
11
|
+
const lcpSrc = paintData.lcpElement.src;
|
|
12
|
+
if (lcpSrc) {
|
|
13
|
+
const matchingRequest = networkRequests.find((r) =>
|
|
14
|
+
r.url === lcpSrc || r.url.includes(lcpSrc),
|
|
15
|
+
);
|
|
16
|
+
if (matchingRequest) {
|
|
17
|
+
result.lcpResource = {
|
|
18
|
+
url: matchingRequest.url,
|
|
19
|
+
size: matchingRequest.size,
|
|
20
|
+
duration: matchingRequest.duration,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (paintData.lcp != null) {
|
|
26
|
+
result.longTasksBeforeLcp = paintData.longTasks
|
|
27
|
+
.filter((t) => t.startTime < paintData.lcp)
|
|
28
|
+
.reduce((sum, t) => sum + t.duration, 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifies page-level main-thread blocking severity based on total long task duration:
|
|
3
|
+
* - >= 200ms → HIGH
|
|
4
|
+
* - 50–199ms → MED
|
|
5
|
+
* - < 50ms → LOW
|
|
6
|
+
*/
|
|
7
|
+
export function classifyBlockingSeverity(totalMs) {
|
|
8
|
+
if (totalMs >= 200) return 'HIGH';
|
|
9
|
+
if (totalMs >= 50) return 'MED';
|
|
10
|
+
return 'LOW';
|
|
11
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A script is render-blocking if:
|
|
3
|
+
* - Located in <head>
|
|
4
|
+
* - NOT async
|
|
5
|
+
* - NOT defer
|
|
6
|
+
* - NOT type="module"
|
|
7
|
+
*/
|
|
8
|
+
export function analyzeRenderBlocking(scripts) {
|
|
9
|
+
return scripts.map((script) => {
|
|
10
|
+
if (script.inline) {
|
|
11
|
+
return { ...script, renderBlocking: false };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const isRenderBlocking =
|
|
15
|
+
script.location === 'head' &&
|
|
16
|
+
!script.async &&
|
|
17
|
+
!script.defer &&
|
|
18
|
+
script.type !== 'module';
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
...script,
|
|
22
|
+
renderBlocking: isRenderBlocking,
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A stylesheet is render-blocking if:
|
|
29
|
+
* - Located in <head>
|
|
30
|
+
* - media is "all" or empty (not print-only, not conditional)
|
|
31
|
+
*/
|
|
32
|
+
export function analyzeRenderBlockingStylesheets(stylesheets) {
|
|
33
|
+
return stylesheets.map((sheet) => {
|
|
34
|
+
const nonBlockingMedia = ['print', 'not all'];
|
|
35
|
+
const isRenderBlocking =
|
|
36
|
+
sheet.location === 'head' &&
|
|
37
|
+
!nonBlockingMedia.includes(sheet.media);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
...sheet,
|
|
41
|
+
renderBlocking: isRenderBlocking,
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const SHOPIFY_CDN_PATTERNS = [
|
|
2
|
+
'cdn.shopify.com',
|
|
3
|
+
'cdn.shopifycdn.net',
|
|
4
|
+
'monorail-edge.shopifysvc.com',
|
|
5
|
+
'shopify.com',
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export function analyzeThirdParty(networkRequests, storefrontDomain, longTasks) {
|
|
9
|
+
const domainMap = new Map();
|
|
10
|
+
|
|
11
|
+
for (const req of networkRequests) {
|
|
12
|
+
let hostname;
|
|
13
|
+
try {
|
|
14
|
+
hostname = new URL(req.url).hostname;
|
|
15
|
+
} catch {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (isFirstParty(hostname, storefrontDomain)) continue;
|
|
20
|
+
|
|
21
|
+
if (!domainMap.has(hostname)) {
|
|
22
|
+
domainMap.set(hostname, {
|
|
23
|
+
domain: hostname,
|
|
24
|
+
requestCount: 0,
|
|
25
|
+
scriptBytes: 0,
|
|
26
|
+
requests: [],
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const entry = domainMap.get(hostname);
|
|
31
|
+
entry.requestCount++;
|
|
32
|
+
if (req.resourceType === 'script' && req.size) {
|
|
33
|
+
entry.scriptBytes += req.size;
|
|
34
|
+
}
|
|
35
|
+
entry.requests.push(req);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const totalLongTaskMs = longTasks.reduce((sum, t) => sum + t.duration, 0);
|
|
39
|
+
const thirdPartyDomains = domainMap.size;
|
|
40
|
+
|
|
41
|
+
const results = [];
|
|
42
|
+
for (const entry of domainMap.values()) {
|
|
43
|
+
const scriptRequestCount = entry.requests.filter(
|
|
44
|
+
(r) => r.resourceType === 'script',
|
|
45
|
+
).length;
|
|
46
|
+
|
|
47
|
+
const proportionalLongTaskMs =
|
|
48
|
+
thirdPartyDomains > 0 && scriptRequestCount > 0
|
|
49
|
+
? Math.round(
|
|
50
|
+
(scriptRequestCount / networkRequests.filter((r) => r.resourceType === 'script').length) *
|
|
51
|
+
totalLongTaskMs,
|
|
52
|
+
)
|
|
53
|
+
: 0;
|
|
54
|
+
|
|
55
|
+
results.push({
|
|
56
|
+
domain: entry.domain,
|
|
57
|
+
requestCount: entry.requestCount,
|
|
58
|
+
scriptBytes: entry.scriptBytes,
|
|
59
|
+
longTaskMs: proportionalLongTaskMs,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
results.sort((a, b) => b.scriptBytes - a.scriptBytes);
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isFirstParty(hostname, storefrontDomain) {
|
|
68
|
+
if (hostname === storefrontDomain) return true;
|
|
69
|
+
if (hostname.endsWith(`.${storefrontDomain}`)) return true;
|
|
70
|
+
|
|
71
|
+
return SHOPIFY_CDN_PATTERNS.some(
|
|
72
|
+
(pattern) => hostname === pattern || hostname.endsWith(`.${pattern}`),
|
|
73
|
+
);
|
|
74
|
+
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { chromium, devices } from 'playwright';
|
|
2
|
+
|
|
3
|
+
const ANALYTICS_DOMAINS = [
|
|
4
|
+
'google-analytics.com',
|
|
5
|
+
'googletagmanager.com',
|
|
6
|
+
'analytics.google.com',
|
|
7
|
+
'facebook.net',
|
|
8
|
+
'connect.facebook.net',
|
|
9
|
+
'hotjar.com',
|
|
10
|
+
'clarity.ms',
|
|
11
|
+
'segment.com',
|
|
12
|
+
'mixpanel.com',
|
|
13
|
+
'amplitude.com',
|
|
14
|
+
'heap.io',
|
|
15
|
+
'fullstory.com',
|
|
16
|
+
'optimizely.com',
|
|
17
|
+
'crazyegg.com',
|
|
18
|
+
'mouseflow.com',
|
|
19
|
+
'lucky-orange.com',
|
|
20
|
+
'tiktok.com',
|
|
21
|
+
'snap.licdn.com',
|
|
22
|
+
'bat.bing.com',
|
|
23
|
+
'doubleclick.net',
|
|
24
|
+
'googlesyndication.com',
|
|
25
|
+
'googleadservices.com',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export async function launchBrowser(config) {
|
|
29
|
+
const browser = await chromium.launch({ headless: true });
|
|
30
|
+
|
|
31
|
+
const contextOptions = config.device === 'mobile'
|
|
32
|
+
? { ...devices['iPhone 12'] }
|
|
33
|
+
: {
|
|
34
|
+
viewport: { width: 1440, height: 900 },
|
|
35
|
+
userAgent:
|
|
36
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const context = await browser.newContext(contextOptions);
|
|
40
|
+
|
|
41
|
+
return { browser, context };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function setupPage(context, config) {
|
|
45
|
+
const page = await context.newPage();
|
|
46
|
+
|
|
47
|
+
if (config.blockAnalytics) {
|
|
48
|
+
await page.route('**/*', (route) => {
|
|
49
|
+
const url = route.request().url();
|
|
50
|
+
const shouldBlock = ANALYTICS_DOMAINS.some((domain) => url.includes(domain));
|
|
51
|
+
if (shouldBlock) {
|
|
52
|
+
return route.abort();
|
|
53
|
+
}
|
|
54
|
+
return route.continue();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return page;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function handleStorefrontPassword(page, config) {
|
|
62
|
+
if (!config.password) return;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const passwordInput = await page.$('input[type="password"]', { timeout: 3000 });
|
|
66
|
+
if (passwordInput) {
|
|
67
|
+
await passwordInput.fill(config.password);
|
|
68
|
+
const submitButton = await page.$('button[type="submit"]');
|
|
69
|
+
if (submitButton) {
|
|
70
|
+
await submitButton.click();
|
|
71
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// No password page — continue
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export function parseArgs(argv) {
|
|
2
|
+
const config = {
|
|
3
|
+
url: null,
|
|
4
|
+
device: 'mobile',
|
|
5
|
+
runs: 1,
|
|
6
|
+
password: null,
|
|
7
|
+
blockAnalytics: false,
|
|
8
|
+
jsonOutput: null,
|
|
9
|
+
pdpUrl: null,
|
|
10
|
+
plpUrl: null,
|
|
11
|
+
failOn: [],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const args = [...argv];
|
|
15
|
+
|
|
16
|
+
while (args.length > 0) {
|
|
17
|
+
const arg = args.shift();
|
|
18
|
+
|
|
19
|
+
if (arg === '--help' || arg === '-h') {
|
|
20
|
+
return { error: 'Show usage' };
|
|
21
|
+
} else if (arg === '--mobile') {
|
|
22
|
+
config.device = 'mobile';
|
|
23
|
+
} else if (arg === '--desktop') {
|
|
24
|
+
config.device = 'desktop';
|
|
25
|
+
} else if (arg === '--runs') {
|
|
26
|
+
const val = args.shift();
|
|
27
|
+
const n = parseInt(val, 10);
|
|
28
|
+
if (isNaN(n) || n < 1) {
|
|
29
|
+
return { error: `Invalid --runs value: ${val}` };
|
|
30
|
+
}
|
|
31
|
+
config.runs = n;
|
|
32
|
+
} else if (arg === '--password') {
|
|
33
|
+
config.password = args.shift();
|
|
34
|
+
if (!config.password) return { error: '--password requires a value' };
|
|
35
|
+
} else if (arg === '--block-analytics') {
|
|
36
|
+
config.blockAnalytics = true;
|
|
37
|
+
} else if (arg === '--json') {
|
|
38
|
+
config.jsonOutput = args.shift();
|
|
39
|
+
if (!config.jsonOutput) return { error: '--json requires a file path' };
|
|
40
|
+
} else if (arg === '--pdp-url') {
|
|
41
|
+
config.pdpUrl = args.shift();
|
|
42
|
+
if (!config.pdpUrl) return { error: '--pdp-url requires a URL' };
|
|
43
|
+
} else if (arg === '--plp-url') {
|
|
44
|
+
config.plpUrl = args.shift();
|
|
45
|
+
if (!config.plpUrl) return { error: '--plp-url requires a URL' };
|
|
46
|
+
} else if (arg === '--fail-on') {
|
|
47
|
+
const val = args.shift();
|
|
48
|
+
if (!val) return { error: '--fail-on requires a value (e.g. render-blocking, tbt:200, lcp:2500)' };
|
|
49
|
+
config.failOn.push(...val.split(',').map((s) => s.trim()));
|
|
50
|
+
} else if (arg.startsWith('--')) {
|
|
51
|
+
return { error: `Unknown flag: ${arg}` };
|
|
52
|
+
} else if (!config.url) {
|
|
53
|
+
config.url = normalizeUrl(arg);
|
|
54
|
+
} else {
|
|
55
|
+
return { error: `Unexpected argument: ${arg}` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!config.url) {
|
|
60
|
+
return { error: 'URL is required' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return config;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeUrl(raw) {
|
|
67
|
+
if (!/^https?:\/\//i.test(raw)) {
|
|
68
|
+
return `https://${raw}`;
|
|
69
|
+
}
|
|
70
|
+
return raw;
|
|
71
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MutationObserver that tracks dynamically injected script elements
|
|
3
|
+
* with timestamps for before/after LCP classification.
|
|
4
|
+
*
|
|
5
|
+
* Uses document as the observe target since document.documentElement
|
|
6
|
+
* may not exist when addInitScript runs.
|
|
7
|
+
*/
|
|
8
|
+
export function buildMutationObserverScript() {
|
|
9
|
+
return `
|
|
10
|
+
window.__auditInjectedScripts = [];
|
|
11
|
+
|
|
12
|
+
const __auditMutationCb = (mutations) => {
|
|
13
|
+
for (const mutation of mutations) {
|
|
14
|
+
for (const node of mutation.addedNodes) {
|
|
15
|
+
if (node.nodeName === 'SCRIPT') {
|
|
16
|
+
window.__auditInjectedScripts.push({
|
|
17
|
+
src: node.src || null,
|
|
18
|
+
timestamp: Math.round(performance.now()),
|
|
19
|
+
async: node.async || false,
|
|
20
|
+
defer: node.defer || false,
|
|
21
|
+
type: node.type || null,
|
|
22
|
+
parentTag: node.parentNode ? node.parentNode.tagName.toLowerCase() : null,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const __auditObserver = new MutationObserver(__auditMutationCb);
|
|
30
|
+
|
|
31
|
+
if (document.documentElement) {
|
|
32
|
+
__auditObserver.observe(document.documentElement, {
|
|
33
|
+
childList: true,
|
|
34
|
+
subtree: true,
|
|
35
|
+
});
|
|
36
|
+
} else {
|
|
37
|
+
__auditObserver.observe(document, {
|
|
38
|
+
childList: true,
|
|
39
|
+
subtree: true,
|
|
40
|
+
});
|
|
41
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
42
|
+
try {
|
|
43
|
+
__auditObserver.observe(document.documentElement, {
|
|
44
|
+
childList: true,
|
|
45
|
+
subtree: true,
|
|
46
|
+
});
|
|
47
|
+
} catch (e) {}
|
|
48
|
+
}, { once: true });
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function collectInjectedScripts(page) {
|
|
54
|
+
return page.evaluate(() => window.__auditInjectedScripts || []);
|
|
55
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export async function collectNavigationTiming(page) {
|
|
2
|
+
return page.evaluate(() => {
|
|
3
|
+
const entry = performance.getEntriesByType('navigation')[0];
|
|
4
|
+
if (!entry) return null;
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
domContentLoadedEventEnd: Math.round(entry.domContentLoadedEventEnd),
|
|
8
|
+
loadEventEnd: Math.round(entry.loadEventEnd),
|
|
9
|
+
responseStart: Math.round(entry.responseStart),
|
|
10
|
+
responseEnd: Math.round(entry.responseEnd),
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export function createNetworkCollector(page) {
|
|
2
|
+
const requests = [];
|
|
3
|
+
const startTimes = new Map();
|
|
4
|
+
|
|
5
|
+
page.on('request', (request) => {
|
|
6
|
+
startTimes.set(request.url(), Date.now());
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
page.on('response', async (response) => {
|
|
10
|
+
const request = response.request();
|
|
11
|
+
const url = request.url();
|
|
12
|
+
const resourceType = request.resourceType();
|
|
13
|
+
const startTime = startTimes.get(url) || null;
|
|
14
|
+
const endTime = Date.now();
|
|
15
|
+
|
|
16
|
+
let size = null;
|
|
17
|
+
try {
|
|
18
|
+
const headers = response.headers();
|
|
19
|
+
if (headers['content-length']) {
|
|
20
|
+
size = parseInt(headers['content-length'], 10);
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// size unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
requests.push({
|
|
27
|
+
url,
|
|
28
|
+
resourceType,
|
|
29
|
+
size,
|
|
30
|
+
startTime,
|
|
31
|
+
endTime,
|
|
32
|
+
duration: startTime ? endTime - startTime : null,
|
|
33
|
+
status: response.status(),
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
page.on('requestfailed', (request) => {
|
|
38
|
+
startTimes.delete(request.url());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
getRequests() {
|
|
43
|
+
return [...requests];
|
|
44
|
+
},
|
|
45
|
+
getScriptRequests() {
|
|
46
|
+
return requests.filter((r) => r.resourceType === 'script');
|
|
47
|
+
},
|
|
48
|
+
getStylesheetRequests() {
|
|
49
|
+
return requests.filter((r) => r.resourceType === 'stylesheet');
|
|
50
|
+
},
|
|
51
|
+
getFontRequests() {
|
|
52
|
+
return requests.filter((r) => r.resourceType === 'font');
|
|
53
|
+
},
|
|
54
|
+
clear() {
|
|
55
|
+
requests.length = 0;
|
|
56
|
+
startTimes.clear();
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injects PerformanceObserver scripts at document start to capture
|
|
3
|
+
* FCP, LCP, long tasks, and layout shifts before any page content loads.
|
|
4
|
+
*/
|
|
5
|
+
export function buildObserverScript() {
|
|
6
|
+
return `
|
|
7
|
+
window.__auditData = {
|
|
8
|
+
fcp: null,
|
|
9
|
+
lcp: null,
|
|
10
|
+
lcpElement: null,
|
|
11
|
+
longTasks: [],
|
|
12
|
+
layoutShifts: [],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
new PerformanceObserver((list) => {
|
|
17
|
+
for (const entry of list.getEntries()) {
|
|
18
|
+
if (entry.name === 'first-contentful-paint') {
|
|
19
|
+
window.__auditData.fcp = Math.round(entry.startTime);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}).observe({ type: 'paint', buffered: true });
|
|
23
|
+
} catch (e) {}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
new PerformanceObserver((list) => {
|
|
27
|
+
for (const entry of list.getEntries()) {
|
|
28
|
+
window.__auditData.lcp = Math.round(entry.startTime);
|
|
29
|
+
const el = entry.element;
|
|
30
|
+
if (el) {
|
|
31
|
+
window.__auditData.lcpElement = {
|
|
32
|
+
tag: el.tagName.toLowerCase(),
|
|
33
|
+
id: el.id || null,
|
|
34
|
+
classes: el.className || null,
|
|
35
|
+
src: el.src || el.currentSrc || null,
|
|
36
|
+
text: el.tagName === 'IMG' ? null : (el.textContent || '').slice(0, 100),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
|
41
|
+
} catch (e) {}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
new PerformanceObserver((list) => {
|
|
45
|
+
for (const entry of list.getEntries()) {
|
|
46
|
+
window.__auditData.longTasks.push({
|
|
47
|
+
startTime: Math.round(entry.startTime),
|
|
48
|
+
duration: Math.round(entry.duration),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}).observe({ type: 'longtask', buffered: true });
|
|
52
|
+
} catch (e) {}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
new PerformanceObserver((list) => {
|
|
56
|
+
for (const entry of list.getEntries()) {
|
|
57
|
+
if (!entry.hadRecentInput) {
|
|
58
|
+
window.__auditData.layoutShifts.push({
|
|
59
|
+
startTime: Math.round(entry.startTime),
|
|
60
|
+
value: entry.value,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}).observe({ type: 'layout-shift', buffered: true });
|
|
65
|
+
} catch (e) {}
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function collectPaintAndTasks(page) {
|
|
70
|
+
return page.evaluate(() => {
|
|
71
|
+
const data = window.__auditData || {};
|
|
72
|
+
|
|
73
|
+
const longTasks = data.longTasks || [];
|
|
74
|
+
const lcp = data.lcp;
|
|
75
|
+
|
|
76
|
+
const tbt = longTasks.reduce(
|
|
77
|
+
(sum, t) => sum + Math.max(0, t.duration - 50),
|
|
78
|
+
0,
|
|
79
|
+
);
|
|
80
|
+
const longtaskTotal = longTasks.reduce((sum, t) => sum + t.duration, 0);
|
|
81
|
+
|
|
82
|
+
const tasksBeforeLcp = lcp != null
|
|
83
|
+
? longTasks.filter((t) => t.startTime < lcp)
|
|
84
|
+
: longTasks;
|
|
85
|
+
const tbtBeforeLcp = tasksBeforeLcp.reduce(
|
|
86
|
+
(sum, t) => sum + Math.max(0, t.duration - 50),
|
|
87
|
+
0,
|
|
88
|
+
);
|
|
89
|
+
const longtaskTotalBeforeLcp = tasksBeforeLcp.reduce(
|
|
90
|
+
(sum, t) => sum + t.duration,
|
|
91
|
+
0,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
fcp: data.fcp,
|
|
96
|
+
lcp,
|
|
97
|
+
lcpElement: data.lcpElement || null,
|
|
98
|
+
longTasks,
|
|
99
|
+
tbt: Math.round(tbt),
|
|
100
|
+
tbtBeforeLcp: Math.round(tbtBeforeLcp),
|
|
101
|
+
longtaskTotal: Math.round(longtaskTotal),
|
|
102
|
+
longtaskTotalBeforeLcp: Math.round(longtaskTotalBeforeLcp),
|
|
103
|
+
layoutShifts: data.layoutShifts || [],
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export async function collectScripts(page) {
|
|
2
|
+
return page.evaluate(() => {
|
|
3
|
+
const scripts = Array.from(document.querySelectorAll('script'));
|
|
4
|
+
const injectedSrcs = new Set(window.__auditInjectedScripts || []);
|
|
5
|
+
|
|
6
|
+
return scripts.map((script) => {
|
|
7
|
+
const src = script.src || null;
|
|
8
|
+
const isInHead = script.closest('head') !== null;
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
src,
|
|
12
|
+
inline: !src,
|
|
13
|
+
async: script.async,
|
|
14
|
+
defer: script.defer,
|
|
15
|
+
type: script.type || null,
|
|
16
|
+
location: isInHead ? 'head' : 'body',
|
|
17
|
+
isInjected: injectedSrcs.has(src) ||
|
|
18
|
+
(script.dataset && script.dataset.auditInjected === 'true'),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export async function collectStylesheets(page) {
|
|
2
|
+
return page.evaluate(() => {
|
|
3
|
+
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
|
|
4
|
+
return links.map((link) => {
|
|
5
|
+
const isInHead = link.closest('head') !== null;
|
|
6
|
+
return {
|
|
7
|
+
href: link.href || null,
|
|
8
|
+
location: isInHead ? 'head' : 'body',
|
|
9
|
+
media: link.media || 'all',
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
package/src/discovery.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export async function discoverPages(page, baseUrl, config) {
|
|
2
|
+
const pages = [
|
|
3
|
+
{ url: baseUrl, pageType: 'home' },
|
|
4
|
+
{ url: new URL('/cart', baseUrl).href, pageType: 'cart' },
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
if (config.pdpUrl) {
|
|
8
|
+
const pdp = config.pdpUrl.startsWith('http')
|
|
9
|
+
? config.pdpUrl
|
|
10
|
+
: new URL(config.pdpUrl, baseUrl).href;
|
|
11
|
+
pages.push({ url: pdp, pageType: 'pdp' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (config.plpUrl) {
|
|
15
|
+
const plp = config.plpUrl.startsWith('http')
|
|
16
|
+
? config.plpUrl
|
|
17
|
+
: new URL(config.plpUrl, baseUrl).href;
|
|
18
|
+
pages.push({ url: plp, pageType: 'plp' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!config.pdpUrl || !config.plpUrl) {
|
|
22
|
+
const discovered = await extractLinksFromPage(page, baseUrl);
|
|
23
|
+
|
|
24
|
+
if (!config.pdpUrl && discovered.pdp) {
|
|
25
|
+
pages.push({ url: discovered.pdp, pageType: 'pdp' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!config.plpUrl && discovered.plp) {
|
|
29
|
+
pages.push({ url: discovered.plp, pageType: 'plp' });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return pages;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function extractLinksFromPage(page, baseUrl) {
|
|
37
|
+
const origin = new URL(baseUrl).origin;
|
|
38
|
+
|
|
39
|
+
const links = await page.evaluate((origin) => {
|
|
40
|
+
const anchors = Array.from(document.querySelectorAll('a[href]'));
|
|
41
|
+
return anchors.map((a) => {
|
|
42
|
+
try {
|
|
43
|
+
return new URL(a.getAttribute('href'), origin).href;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}).filter(Boolean);
|
|
48
|
+
}, origin);
|
|
49
|
+
|
|
50
|
+
let pdp = null;
|
|
51
|
+
let plp = null;
|
|
52
|
+
|
|
53
|
+
for (const link of links) {
|
|
54
|
+
try {
|
|
55
|
+
const url = new URL(link);
|
|
56
|
+
if (url.origin !== origin) continue;
|
|
57
|
+
|
|
58
|
+
if (!pdp && /^\/products\/[^/]+$/i.test(url.pathname)) {
|
|
59
|
+
pdp = link;
|
|
60
|
+
}
|
|
61
|
+
if (!plp && /^\/collections\/[^/]+$/i.test(url.pathname)) {
|
|
62
|
+
plp = link;
|
|
63
|
+
}
|
|
64
|
+
if (pdp && plp) break;
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { pdp, plp };
|
|
71
|
+
}
|
package/src/findings.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export function generateFindings(pageResult) {
|
|
2
|
+
const findings = [];
|
|
3
|
+
const { scripts, stylesheets, metrics, lcpAnalysis, thirdParty, injectedScripts } = pageResult;
|
|
4
|
+
|
|
5
|
+
const renderBlockingScripts = scripts.filter((s) => s.renderBlocking);
|
|
6
|
+
if (renderBlockingScripts.length > 0) {
|
|
7
|
+
findings.push({
|
|
8
|
+
type: 'render-blocking-scripts',
|
|
9
|
+
severity: 'HIGH',
|
|
10
|
+
message: `${renderBlockingScripts.length} render-blocking script(s) found in <head>`,
|
|
11
|
+
details: renderBlockingScripts.map((s) => s.src).filter(Boolean),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const renderBlockingSheets = stylesheets.filter((s) => s.renderBlocking);
|
|
16
|
+
if (renderBlockingSheets.length > 0) {
|
|
17
|
+
findings.push({
|
|
18
|
+
type: 'render-blocking-stylesheets',
|
|
19
|
+
severity: 'MED',
|
|
20
|
+
message: `${renderBlockingSheets.length} render-blocking stylesheet(s) in <head>`,
|
|
21
|
+
details: renderBlockingSheets.map((s) => s.href).filter(Boolean),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (metrics.tbtBeforeLcp >= 200) {
|
|
26
|
+
findings.push({
|
|
27
|
+
type: 'high-tbt-before-lcp',
|
|
28
|
+
severity: 'HIGH',
|
|
29
|
+
message: `TBT before LCP is ${metrics.tbtBeforeLcp}ms (threshold: 200ms)`,
|
|
30
|
+
});
|
|
31
|
+
} else if (metrics.tbt >= 200) {
|
|
32
|
+
findings.push({
|
|
33
|
+
type: 'high-tbt',
|
|
34
|
+
severity: 'HIGH',
|
|
35
|
+
message: `Total Blocking Time is ${metrics.tbt}ms (threshold: 200ms)`,
|
|
36
|
+
});
|
|
37
|
+
} else if (metrics.tbt >= 50) {
|
|
38
|
+
findings.push({
|
|
39
|
+
type: 'moderate-tbt',
|
|
40
|
+
severity: 'MED',
|
|
41
|
+
message: `Total Blocking Time is ${metrics.tbt}ms (threshold: 50ms)`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (metrics.lcp > 2500) {
|
|
46
|
+
findings.push({
|
|
47
|
+
type: 'slow-lcp',
|
|
48
|
+
severity: 'HIGH',
|
|
49
|
+
message: `LCP is ${metrics.lcp}ms (threshold: 2500ms)`,
|
|
50
|
+
});
|
|
51
|
+
} else if (metrics.lcp > 1800) {
|
|
52
|
+
findings.push({
|
|
53
|
+
type: 'moderate-lcp',
|
|
54
|
+
severity: 'MED',
|
|
55
|
+
message: `LCP is ${metrics.lcp}ms (threshold: 1800ms)`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (lcpAnalysis.longTasksBeforeLcp > 100) {
|
|
60
|
+
findings.push({
|
|
61
|
+
type: 'long-tasks-before-lcp',
|
|
62
|
+
severity: 'HIGH',
|
|
63
|
+
message: `${lcpAnalysis.longTasksBeforeLcp}ms of long tasks occurred before LCP`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const beforeLcp = injectedScripts.beforeLcp || [];
|
|
68
|
+
const afterLcp = injectedScripts.afterLcp || [];
|
|
69
|
+
const allInjected = injectedScripts.all || [];
|
|
70
|
+
|
|
71
|
+
if (beforeLcp.length > 0) {
|
|
72
|
+
findings.push({
|
|
73
|
+
type: 'injected-scripts-before-lcp',
|
|
74
|
+
severity: 'HIGH',
|
|
75
|
+
message: `${beforeLcp.length} script(s) injected before LCP`,
|
|
76
|
+
details: beforeLcp.filter((s) => s.src).map((s) => s.src),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (allInjected.length > 0 && beforeLcp.length === 0) {
|
|
81
|
+
findings.push({
|
|
82
|
+
type: 'injected-scripts',
|
|
83
|
+
severity: 'MED',
|
|
84
|
+
message: `${allInjected.length} dynamically injected script(s) detected (all after LCP)`,
|
|
85
|
+
details: allInjected.filter((s) => s.src).map((s) => s.src).slice(0, 10),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const heavyThirdParties = thirdParty.filter((tp) => tp.scriptBytes > 50000);
|
|
90
|
+
for (const tp of heavyThirdParties) {
|
|
91
|
+
findings.push({
|
|
92
|
+
type: 'heavy-third-party',
|
|
93
|
+
severity: 'MED',
|
|
94
|
+
message: `${tp.domain}: ${formatBytes(tp.scriptBytes)} of script loaded (${tp.requestCount} requests)`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return findings;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatBytes(bytes) {
|
|
102
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
103
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
104
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
105
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { launchBrowser, setupPage, handleStorefrontPassword } from './browser.js';
|
|
2
|
+
import { discoverPages } from './discovery.js';
|
|
3
|
+
import { collectNavigationTiming } from './collectors/navigation.js';
|
|
4
|
+
import { buildObserverScript, collectPaintAndTasks } from './collectors/paint-and-tasks.js';
|
|
5
|
+
import { buildMutationObserverScript, collectInjectedScripts } from './collectors/injected-scripts.js';
|
|
6
|
+
import { collectScripts } from './collectors/scripts.js';
|
|
7
|
+
import { collectStylesheets } from './collectors/stylesheets.js';
|
|
8
|
+
import { createNetworkCollector } from './collectors/network.js';
|
|
9
|
+
import { analyzeRenderBlocking, analyzeRenderBlockingStylesheets } from './analyzers/render-blocking.js';
|
|
10
|
+
import { analyzeLcp } from './analyzers/lcp.js';
|
|
11
|
+
import { analyzeThirdParty } from './analyzers/third-party.js';
|
|
12
|
+
import { generateFindings } from './findings.js';
|
|
13
|
+
import { printReport, writeJsonReport } from './reporter.js';
|
|
14
|
+
|
|
15
|
+
export async function runAudit(config) {
|
|
16
|
+
const startTime = Date.now();
|
|
17
|
+
console.log(`\n Starting Shopify audit for ${config.url}`);
|
|
18
|
+
console.log(` Device: ${config.device} | Runs: ${config.runs}`);
|
|
19
|
+
|
|
20
|
+
const { browser, context } = await launchBrowser(config);
|
|
21
|
+
const storefrontDomain = new URL(config.url).hostname;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const initScripts =
|
|
25
|
+
buildObserverScript() + '\n' + buildMutationObserverScript();
|
|
26
|
+
await context.addInitScript({ content: initScripts });
|
|
27
|
+
|
|
28
|
+
console.log('\n Discovering pages...');
|
|
29
|
+
const discoveryPage = await setupPage(context, config);
|
|
30
|
+
await navigateWithRetry(discoveryPage, config.url, config);
|
|
31
|
+
const pagesToTest = await discoverPages(discoveryPage, config.url, config);
|
|
32
|
+
await discoveryPage.close();
|
|
33
|
+
|
|
34
|
+
console.log(` Found ${pagesToTest.length} pages to test:`);
|
|
35
|
+
for (const p of pagesToTest) {
|
|
36
|
+
console.log(` • ${p.pageType}: ${p.url}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const report = {
|
|
40
|
+
runMeta: {
|
|
41
|
+
timestamp: new Date().toISOString(),
|
|
42
|
+
device: config.device,
|
|
43
|
+
url: config.url,
|
|
44
|
+
runs: config.runs,
|
|
45
|
+
},
|
|
46
|
+
pages: [],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
for (const pageInfo of pagesToTest) {
|
|
50
|
+
console.log(`\n Testing ${pageInfo.pageType.toUpperCase()}: ${pageInfo.url}`);
|
|
51
|
+
|
|
52
|
+
const pageResults = [];
|
|
53
|
+
for (let run = 0; run < config.runs; run++) {
|
|
54
|
+
if (config.runs > 1) {
|
|
55
|
+
console.log(` Run ${run + 1}/${config.runs}...`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = await auditSinglePage(context, pageInfo, config, storefrontDomain);
|
|
59
|
+
pageResults.push(result);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const averaged = config.runs > 1 ? averageRuns(pageResults) : pageResults[0];
|
|
63
|
+
report.pages.push(averaged);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
67
|
+
console.log(`\n Audit complete in ${elapsed}s`);
|
|
68
|
+
|
|
69
|
+
printReport(report);
|
|
70
|
+
|
|
71
|
+
if (config.jsonOutput) {
|
|
72
|
+
await writeJsonReport(report, config.jsonOutput);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return report;
|
|
76
|
+
} finally {
|
|
77
|
+
await browser.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function auditSinglePage(context, pageInfo, config, storefrontDomain) {
|
|
82
|
+
const page = await setupPage(context, config);
|
|
83
|
+
const networkCollector = createNetworkCollector(page);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await navigateWithRetry(page, pageInfo.url, config);
|
|
87
|
+
|
|
88
|
+
await page.waitForLoadState('load', { timeout: 30000 });
|
|
89
|
+
await page.waitForTimeout(2000);
|
|
90
|
+
|
|
91
|
+
const [navTiming, paintData, scripts, stylesheets, injectedScripts] = await Promise.all([
|
|
92
|
+
collectNavigationTiming(page),
|
|
93
|
+
collectPaintAndTasks(page),
|
|
94
|
+
collectScripts(page),
|
|
95
|
+
collectStylesheets(page),
|
|
96
|
+
collectInjectedScripts(page),
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const networkRequests = networkCollector.getRequests();
|
|
100
|
+
|
|
101
|
+
const analyzedScripts = analyzeRenderBlocking(scripts);
|
|
102
|
+
const analyzedStylesheets = analyzeRenderBlockingStylesheets(stylesheets);
|
|
103
|
+
const lcpAnalysis = analyzeLcp(paintData, networkRequests);
|
|
104
|
+
const thirdParty = analyzeThirdParty(
|
|
105
|
+
networkRequests,
|
|
106
|
+
storefrontDomain,
|
|
107
|
+
paintData.longTasks,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const lcpTime = paintData.lcp;
|
|
111
|
+
const injectedBeforeLcp = lcpTime != null
|
|
112
|
+
? injectedScripts.filter((s) => s.timestamp <= lcpTime)
|
|
113
|
+
: injectedScripts;
|
|
114
|
+
const injectedAfterLcp = lcpTime != null
|
|
115
|
+
? injectedScripts.filter((s) => s.timestamp > lcpTime)
|
|
116
|
+
: [];
|
|
117
|
+
|
|
118
|
+
const metrics = {
|
|
119
|
+
fcp: paintData.fcp,
|
|
120
|
+
lcp: paintData.lcp,
|
|
121
|
+
tbt: paintData.tbt,
|
|
122
|
+
tbtBeforeLcp: paintData.tbtBeforeLcp,
|
|
123
|
+
longtaskTotal: paintData.longtaskTotal,
|
|
124
|
+
longtaskTotalBeforeLcp: paintData.longtaskTotalBeforeLcp,
|
|
125
|
+
dcl: navTiming?.domContentLoadedEventEnd || null,
|
|
126
|
+
load: navTiming?.loadEventEnd || null,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const pageResult = {
|
|
130
|
+
url: pageInfo.url,
|
|
131
|
+
pageType: pageInfo.pageType,
|
|
132
|
+
metrics,
|
|
133
|
+
longTasks: paintData.longTasks,
|
|
134
|
+
scripts: analyzedScripts,
|
|
135
|
+
stylesheets: analyzedStylesheets,
|
|
136
|
+
injectedScripts: {
|
|
137
|
+
all: injectedScripts,
|
|
138
|
+
beforeLcp: injectedBeforeLcp,
|
|
139
|
+
afterLcp: injectedAfterLcp,
|
|
140
|
+
},
|
|
141
|
+
lcpAnalysis,
|
|
142
|
+
thirdParty,
|
|
143
|
+
networkSummary: {
|
|
144
|
+
totalRequests: networkRequests.length,
|
|
145
|
+
scriptRequests: networkCollector.getScriptRequests().length,
|
|
146
|
+
stylesheetRequests: networkCollector.getStylesheetRequests().length,
|
|
147
|
+
fontRequests: networkCollector.getFontRequests().length,
|
|
148
|
+
},
|
|
149
|
+
findings: [],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
pageResult.findings = generateFindings(pageResult);
|
|
153
|
+
|
|
154
|
+
return pageResult;
|
|
155
|
+
} finally {
|
|
156
|
+
await page.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function navigateWithRetry(page, url, config, retries = 2) {
|
|
161
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
162
|
+
try {
|
|
163
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
164
|
+
await handleStorefrontPassword(page, config);
|
|
165
|
+
return;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (attempt === retries) throw err;
|
|
168
|
+
console.log(` Retry ${attempt + 1}/${retries} for ${url}...`);
|
|
169
|
+
await page.waitForTimeout(2000);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function averageRuns(runs) {
|
|
175
|
+
const base = { ...runs[0] };
|
|
176
|
+
const metricKeys = ['fcp', 'lcp', 'tbt', 'tbtBeforeLcp', 'longtaskTotal', 'longtaskTotalBeforeLcp', 'dcl', 'load'];
|
|
177
|
+
|
|
178
|
+
base.metrics = {};
|
|
179
|
+
for (const key of metricKeys) {
|
|
180
|
+
const values = runs.map((r) => r.metrics[key]).filter((v) => v != null);
|
|
181
|
+
base.metrics[key] = values.length > 0
|
|
182
|
+
? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
|
|
183
|
+
: null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const last = runs[runs.length - 1];
|
|
187
|
+
base.longTasks = last.longTasks;
|
|
188
|
+
base.scripts = last.scripts;
|
|
189
|
+
base.stylesheets = last.stylesheets;
|
|
190
|
+
base.injectedScripts = last.injectedScripts;
|
|
191
|
+
base.lcpAnalysis = last.lcpAnalysis;
|
|
192
|
+
base.thirdParty = last.thirdParty;
|
|
193
|
+
base.findings = last.findings;
|
|
194
|
+
|
|
195
|
+
return base;
|
|
196
|
+
}
|
package/src/reporter.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { writeFile } from 'fs/promises';
|
|
2
|
+
|
|
3
|
+
const SEVERITY_COLORS = {
|
|
4
|
+
HIGH: '\x1b[31m',
|
|
5
|
+
MED: '\x1b[33m',
|
|
6
|
+
LOW: '\x1b[32m',
|
|
7
|
+
};
|
|
8
|
+
const RESET = '\x1b[0m';
|
|
9
|
+
const BOLD = '\x1b[1m';
|
|
10
|
+
const DIM = '\x1b[2m';
|
|
11
|
+
|
|
12
|
+
export function printReport(report) {
|
|
13
|
+
console.log('');
|
|
14
|
+
console.log(`${BOLD}═══════════════════════════════════════════════════════════${RESET}`);
|
|
15
|
+
console.log(`${BOLD} Shopify Audit Report${RESET}`);
|
|
16
|
+
console.log(`${BOLD}═══════════════════════════════════════════════════════════${RESET}`);
|
|
17
|
+
console.log(` Device: ${report.runMeta.device}`);
|
|
18
|
+
console.log(` Time: ${report.runMeta.timestamp}`);
|
|
19
|
+
console.log(` Runs: ${report.runMeta.runs}`);
|
|
20
|
+
console.log('');
|
|
21
|
+
|
|
22
|
+
for (const page of report.pages) {
|
|
23
|
+
printPageReport(page);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printPageReport(page) {
|
|
28
|
+
console.log(`${BOLD}───────────────────────────────────────────────────────────${RESET}`);
|
|
29
|
+
console.log(`${BOLD} ${page.pageType.toUpperCase()}${RESET} — ${page.url}`);
|
|
30
|
+
console.log(`${BOLD}───────────────────────────────────────────────────────────${RESET}`);
|
|
31
|
+
console.log('');
|
|
32
|
+
|
|
33
|
+
printMetrics(page.metrics);
|
|
34
|
+
printLcp(page);
|
|
35
|
+
printRenderBlockingScripts(page.scripts);
|
|
36
|
+
printRenderBlockingStylesheets(page.stylesheets);
|
|
37
|
+
printInjectedScripts(page.injectedScripts, page.metrics.lcp);
|
|
38
|
+
printThirdParty(page.thirdParty);
|
|
39
|
+
printFindings(page.findings);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function printMetrics(metrics) {
|
|
43
|
+
console.log(` ${BOLD}Core Metrics${RESET}`);
|
|
44
|
+
console.log(` FCP: ${colorMetric(metrics.fcp, 1800, 3000)}ms`);
|
|
45
|
+
console.log(` LCP: ${colorMetric(metrics.lcp, 2500, 4000)}ms`);
|
|
46
|
+
console.log(` TBT: ${colorMetric(metrics.tbt, 200, 600)}ms`);
|
|
47
|
+
console.log(` TBT (0→LCP): ${colorMetric(metrics.tbtBeforeLcp, 200, 600)}ms`);
|
|
48
|
+
console.log(` Long Tasks: ${metrics.longtaskTotal}ms total (${metrics.longtaskTotalBeforeLcp}ms before LCP)`);
|
|
49
|
+
console.log(` DCL: ${metrics.dcl}ms`);
|
|
50
|
+
console.log(` Load: ${metrics.load}ms`);
|
|
51
|
+
console.log('');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printLcp(page) {
|
|
55
|
+
if (!page.lcpAnalysis?.lcpElement) return;
|
|
56
|
+
|
|
57
|
+
const el = page.lcpAnalysis.lcpElement;
|
|
58
|
+
console.log(` ${BOLD}LCP Element${RESET}`);
|
|
59
|
+
console.log(` Tag: <${el.tag}>`);
|
|
60
|
+
if (el.id) console.log(` ID: #${el.id}`);
|
|
61
|
+
if (el.classes) console.log(` Classes: .${el.classes.replace(/\s+/g, '.')}`);
|
|
62
|
+
if (el.src) console.log(` Src: ${truncate(el.src, 80)}`);
|
|
63
|
+
if (page.lcpAnalysis.lcpResource) {
|
|
64
|
+
const res = page.lcpAnalysis.lcpResource;
|
|
65
|
+
console.log(` Resource: ${formatBytes(res.size)} loaded in ${res.duration}ms`);
|
|
66
|
+
}
|
|
67
|
+
console.log(` Long tasks before LCP: ${page.lcpAnalysis.longTasksBeforeLcp}ms`);
|
|
68
|
+
console.log('');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function printRenderBlockingScripts(scripts) {
|
|
72
|
+
const blocking = scripts.filter((s) => s.renderBlocking);
|
|
73
|
+
if (blocking.length === 0) return;
|
|
74
|
+
|
|
75
|
+
console.log(` ${BOLD}Render-Blocking Scripts${RESET} ${SEVERITY_COLORS.HIGH}[HIGH]${RESET}`);
|
|
76
|
+
for (const s of blocking) {
|
|
77
|
+
console.log(` ${DIM}•${RESET} ${truncate(s.src, 80)}`);
|
|
78
|
+
}
|
|
79
|
+
console.log('');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function printRenderBlockingStylesheets(stylesheets) {
|
|
83
|
+
if (!stylesheets) return;
|
|
84
|
+
const blocking = stylesheets.filter((s) => s.renderBlocking);
|
|
85
|
+
if (blocking.length === 0) return;
|
|
86
|
+
|
|
87
|
+
console.log(` ${BOLD}Render-Blocking Stylesheets${RESET} (${blocking.length})`);
|
|
88
|
+
for (const s of blocking) {
|
|
89
|
+
console.log(` ${DIM}•${RESET} ${truncate(s.href, 80)}`);
|
|
90
|
+
}
|
|
91
|
+
console.log('');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function printInjectedScripts(injectedScripts, lcpTime) {
|
|
95
|
+
if (!injectedScripts) return;
|
|
96
|
+
const { all, beforeLcp, afterLcp } = injectedScripts;
|
|
97
|
+
if (!all || all.length === 0) return;
|
|
98
|
+
|
|
99
|
+
const withSrc = all.filter((s) => s.src);
|
|
100
|
+
console.log(` ${BOLD}Dynamically Injected Scripts${RESET} (${all.length} total, ${withSrc.length} external)`);
|
|
101
|
+
|
|
102
|
+
if (beforeLcp.length > 0) {
|
|
103
|
+
console.log(` ${SEVERITY_COLORS.HIGH}Before LCP (${beforeLcp.length}):${RESET}`);
|
|
104
|
+
for (const s of beforeLcp.filter((s) => s.src).slice(0, 10)) {
|
|
105
|
+
console.log(` ${DIM}•${RESET} ${truncate(s.src, 74)} ${DIM}@${s.timestamp}ms${RESET}`);
|
|
106
|
+
}
|
|
107
|
+
const inlineBeforeLcp = beforeLcp.filter((s) => !s.src).length;
|
|
108
|
+
if (inlineBeforeLcp > 0) {
|
|
109
|
+
console.log(` ${DIM}+ ${inlineBeforeLcp} inline script(s)${RESET}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (afterLcp.length > 0) {
|
|
114
|
+
console.log(` After LCP (${afterLcp.length}):`);
|
|
115
|
+
for (const s of afterLcp.filter((s) => s.src).slice(0, 5)) {
|
|
116
|
+
console.log(` ${DIM}•${RESET} ${truncate(s.src, 74)} ${DIM}@${s.timestamp}ms${RESET}`);
|
|
117
|
+
}
|
|
118
|
+
const remaining = afterLcp.filter((s) => s.src).length - 5;
|
|
119
|
+
if (remaining > 0) {
|
|
120
|
+
console.log(` ${DIM} ... and ${remaining} more${RESET}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
console.log('');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function printThirdParty(thirdParty) {
|
|
127
|
+
if (!thirdParty || thirdParty.length === 0) return;
|
|
128
|
+
|
|
129
|
+
console.log(` ${BOLD}Third-Party Domains${RESET}`);
|
|
130
|
+
for (const tp of thirdParty.slice(0, 15)) {
|
|
131
|
+
const bytes = tp.scriptBytes > 0 ? ` (${formatBytes(tp.scriptBytes)} scripts)` : '';
|
|
132
|
+
console.log(` ${DIM}•${RESET} ${tp.domain}: ${tp.requestCount} req${bytes}`);
|
|
133
|
+
}
|
|
134
|
+
if (thirdParty.length > 15) {
|
|
135
|
+
console.log(` ${DIM} ... and ${thirdParty.length - 15} more${RESET}`);
|
|
136
|
+
}
|
|
137
|
+
console.log('');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function printFindings(findings) {
|
|
141
|
+
if (!findings || findings.length === 0) return;
|
|
142
|
+
|
|
143
|
+
console.log(` ${BOLD}Findings${RESET}`);
|
|
144
|
+
for (const f of findings) {
|
|
145
|
+
const color = SEVERITY_COLORS[f.severity] || '';
|
|
146
|
+
console.log(` ${color}[${f.severity}]${RESET} ${f.message}`);
|
|
147
|
+
}
|
|
148
|
+
console.log('');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function writeJsonReport(report, filepath) {
|
|
152
|
+
const json = JSON.stringify(report, null, 2);
|
|
153
|
+
await writeFile(filepath, json, 'utf-8');
|
|
154
|
+
console.log(`\n JSON report written to: ${filepath}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function colorMetric(value, warningThreshold, errorThreshold) {
|
|
158
|
+
if (value == null) return `${DIM}N/A${RESET}`;
|
|
159
|
+
if (value >= errorThreshold) return `${SEVERITY_COLORS.HIGH}${value}${RESET}`;
|
|
160
|
+
if (value >= warningThreshold) return `${SEVERITY_COLORS.MED}${value}${RESET}`;
|
|
161
|
+
return `${SEVERITY_COLORS.LOW}${value}${RESET}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function truncate(str, maxLen) {
|
|
165
|
+
if (!str) return '';
|
|
166
|
+
return str.length > maxLen ? str.slice(0, maxLen - 3) + '...' : str;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatBytes(bytes) {
|
|
170
|
+
if (bytes == null) return 'unknown';
|
|
171
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
172
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
173
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
174
|
+
}
|