@shop-on/theme-cli 0.0.0-stage → 0.2.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/dist/LICENSE.md +39 -0
- package/dist/audit/command.mjs +185 -0
- package/dist/audit/env.mjs +65 -0
- package/dist/audit/lighthouse.mjs +67 -0
- package/dist/audit/report.mjs +37 -0
- package/dist/audit/routes.mjs +91 -0
- package/dist/audit/score.mjs +42 -0
- package/dist/audit/server.mjs +190 -0
- package/dist/audit/store.mjs +72 -0
- package/dist/auth/api.mjs +44 -0
- package/dist/auth/credentials.mjs +104 -0
- package/dist/auth/oauth.mjs +181 -0
- package/dist/index.mjs +32 -0
- package/dist/login.mjs +49 -0
- package/dist/open-url.mjs +18 -0
- package/dist/theme/build.mjs +26 -0
- package/dist/theme/credentials.mjs +77 -0
- package/dist/theme/dev.mjs +212 -0
- package/dist/theme/init.mjs +147 -0
- package/dist/theme/pack.mjs +124 -0
- package/package.json +31 -3
- package/README.md +0 -3
package/dist/LICENSE.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Cody Theme Development License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cody LLC. All rights reserved except as granted below.
|
|
4
|
+
|
|
5
|
+
"The Software" is this package, its source, and its documentation. "The
|
|
6
|
+
Platform" is Cody, the commerce platform operated by Cody LLC.
|
|
7
|
+
|
|
8
|
+
## 1. Grant
|
|
9
|
+
|
|
10
|
+
You may use, copy and modify the Software, and distribute modified copies, solely
|
|
11
|
+
to develop, test, and distribute themes, apps and other extensions that run on
|
|
12
|
+
the Platform.
|
|
13
|
+
|
|
14
|
+
## 2. Restrictions
|
|
15
|
+
|
|
16
|
+
You may not:
|
|
17
|
+
|
|
18
|
+
1. use the Software, or anything derived from it, with any commerce platform,
|
|
19
|
+
storefront or hosting service other than the Platform;
|
|
20
|
+
2. use the Software to build a product that competes with the Platform;
|
|
21
|
+
3. sublicense the Software, or distribute it other than as part of a theme,
|
|
22
|
+
app or extension for the Platform;
|
|
23
|
+
4. remove or alter this license or any copyright notice in the Software.
|
|
24
|
+
|
|
25
|
+
## 3. Your work
|
|
26
|
+
|
|
27
|
+
Themes, apps and extensions you build with the Software are yours. The parts of
|
|
28
|
+
the Software they include stay under this license.
|
|
29
|
+
|
|
30
|
+
## 4. Termination
|
|
31
|
+
|
|
32
|
+
This license ends automatically if you break any of its terms. You must then
|
|
33
|
+
stop using the Software and delete your copies of it.
|
|
34
|
+
|
|
35
|
+
## 5. No warranty
|
|
36
|
+
|
|
37
|
+
The Software is provided "as is", without warranty of any kind. Cody LLC is not
|
|
38
|
+
liable for any claim, damages or other liability arising from the Software or
|
|
39
|
+
its use.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { MissingEnvError, loadThemeEnv } from "./env.mjs";
|
|
3
|
+
import { openBrowser } from "./lighthouse.mjs";
|
|
4
|
+
import { average, pageScore, verdict } from "./score.mjs";
|
|
5
|
+
import { BAR, formatReport } from "./report.mjs";
|
|
6
|
+
import { auditRoutes, checkRoutes } from "./routes.mjs";
|
|
7
|
+
import { startBuiltServer } from "./server.mjs";
|
|
8
|
+
import { fetchStoreData, storeProblems } from "./store.mjs";
|
|
9
|
+
import { runThemeBuild } from "../theme/build.mjs";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { parseArgs, styleText } from "node:util";
|
|
13
|
+
//#region src/audit/command.ts
|
|
14
|
+
const USAGE = `usage: shop-on theme audit [--theme <dir>] [--runs 3] [--json] [--skip-build]
|
|
15
|
+
|
|
16
|
+
--theme <dir> the theme to audit (default: the current directory)
|
|
17
|
+
--runs <n> Lighthouse runs per page and form factor; the median counts (default: 3)
|
|
18
|
+
--json print the report as JSON, for CI
|
|
19
|
+
--skip-build serve the existing .output instead of running \`pnpm build\``;
|
|
20
|
+
const LIGHTHOUSE_PAGES = [
|
|
21
|
+
"index",
|
|
22
|
+
"product",
|
|
23
|
+
"collection"
|
|
24
|
+
];
|
|
25
|
+
const FORM_FACTORS = ["mobile", "desktop"];
|
|
26
|
+
const progress = (text) => styleText("dim", `› ${text}`);
|
|
27
|
+
const processIo = {
|
|
28
|
+
out: (text) => process.stdout.write(`${text}\n`),
|
|
29
|
+
err: (text) => process.stderr.write(`${text}\n`)
|
|
30
|
+
};
|
|
31
|
+
const defaultDeps = {
|
|
32
|
+
fetchStoreData,
|
|
33
|
+
build: runThemeBuild,
|
|
34
|
+
startServer: startBuiltServer,
|
|
35
|
+
openBrowser
|
|
36
|
+
};
|
|
37
|
+
/** Runs the audit and returns the exit code. */
|
|
38
|
+
async function runAudit({ argv, cwd = process.cwd(), io = processIo, deps: overrides = {}, processEnv = process.env }) {
|
|
39
|
+
const deps = {
|
|
40
|
+
...defaultDeps,
|
|
41
|
+
...overrides
|
|
42
|
+
};
|
|
43
|
+
let values;
|
|
44
|
+
try {
|
|
45
|
+
({values} = parseArgs({
|
|
46
|
+
args: argv,
|
|
47
|
+
options: {
|
|
48
|
+
theme: { type: "string" },
|
|
49
|
+
runs: {
|
|
50
|
+
type: "string",
|
|
51
|
+
default: "3"
|
|
52
|
+
},
|
|
53
|
+
json: {
|
|
54
|
+
type: "boolean",
|
|
55
|
+
default: false
|
|
56
|
+
},
|
|
57
|
+
"skip-build": {
|
|
58
|
+
type: "boolean",
|
|
59
|
+
default: false
|
|
60
|
+
},
|
|
61
|
+
help: {
|
|
62
|
+
type: "boolean",
|
|
63
|
+
short: "h",
|
|
64
|
+
default: false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
io.err(`${err.message}\n\n${USAGE}`);
|
|
70
|
+
return 2;
|
|
71
|
+
}
|
|
72
|
+
if (values.help) {
|
|
73
|
+
io.out(USAGE);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
const runs = Number(values.runs);
|
|
77
|
+
if (!Number.isInteger(runs) || runs < 1) {
|
|
78
|
+
io.err(`--runs must be a whole number of 1 or more\n\n${USAGE}`);
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
const themeDir = path.resolve(cwd, values.theme ?? ".");
|
|
82
|
+
const report = {
|
|
83
|
+
ok: false,
|
|
84
|
+
problems: [],
|
|
85
|
+
routes: [],
|
|
86
|
+
skippedRoutes: [],
|
|
87
|
+
lighthouse: null
|
|
88
|
+
};
|
|
89
|
+
const finish = () => {
|
|
90
|
+
report.ok = report.problems.length === 0;
|
|
91
|
+
io.out(values.json ? JSON.stringify(report, null, 2) : formatReport(report));
|
|
92
|
+
return report.ok ? 0 : 1;
|
|
93
|
+
};
|
|
94
|
+
const fail = (...problems) => {
|
|
95
|
+
report.problems.push(...problems);
|
|
96
|
+
return finish();
|
|
97
|
+
};
|
|
98
|
+
if (!existsSync(path.join(themeDir, "package.json"))) return fail(`${themeDir} is not a theme: it has no package.json`);
|
|
99
|
+
let env;
|
|
100
|
+
try {
|
|
101
|
+
({env} = loadThemeEnv(themeDir, processEnv));
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err instanceof MissingEnvError) return fail(`the theme's storefront credentials are incomplete: ${err.message}`);
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
io.err(progress(`reading the store at ${env.STOREFRONT_API_URL}`));
|
|
107
|
+
let store;
|
|
108
|
+
try {
|
|
109
|
+
store = await deps.fetchStoreData(env);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return fail(err.message);
|
|
112
|
+
}
|
|
113
|
+
const missing = storeProblems(store);
|
|
114
|
+
if (missing.length) return fail(...missing.map((m) => `store ${env.STORE_SLUG}: ${m}`));
|
|
115
|
+
const cleanups = [];
|
|
116
|
+
const cleanUp = () => Promise.allSettled(cleanups.splice(0).reverse().map((c) => c()));
|
|
117
|
+
const exitCode = (signal) => signal === "SIGINT" ? 130 : 143;
|
|
118
|
+
const force = (signal) => process.exit(exitCode(signal));
|
|
119
|
+
const onSignal = (signal) => {
|
|
120
|
+
process.on("SIGINT", force);
|
|
121
|
+
process.on("SIGTERM", force);
|
|
122
|
+
cleanUp().finally(() => process.exit(exitCode(signal)));
|
|
123
|
+
};
|
|
124
|
+
process.once("SIGINT", onSignal);
|
|
125
|
+
process.once("SIGTERM", onSignal);
|
|
126
|
+
try {
|
|
127
|
+
if (!values["skip-build"]) {
|
|
128
|
+
io.err(progress("building"));
|
|
129
|
+
const code = await deps.build(themeDir);
|
|
130
|
+
if (code !== 0) return fail(`the build failed (exit ${code})`);
|
|
131
|
+
}
|
|
132
|
+
io.err(progress("starting the built server"));
|
|
133
|
+
let server;
|
|
134
|
+
try {
|
|
135
|
+
server = await deps.startServer(themeDir, env);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return fail(`the built server did not start: ${err.message}`);
|
|
138
|
+
}
|
|
139
|
+
cleanups.push(() => server.stop());
|
|
140
|
+
const { routes, skipped } = auditRoutes(store);
|
|
141
|
+
report.routes = await checkRoutes(server.url, routes);
|
|
142
|
+
report.skippedRoutes = skipped;
|
|
143
|
+
for (const r of report.routes.filter((r) => !r.ok)) report.problems.push(`${r.template} ${r.path} answered ${r.status ?? r.error}, expected ${r.expect}`);
|
|
144
|
+
const pages = LIGHTHOUSE_PAGES.map((page) => report.routes.find((r) => r.template === page));
|
|
145
|
+
if (pages.some((p) => !p.ok)) {
|
|
146
|
+
report.problems.push("Lighthouse skipped: a page it measures did not answer 200");
|
|
147
|
+
return finish();
|
|
148
|
+
}
|
|
149
|
+
const browser = await deps.openBrowser();
|
|
150
|
+
cleanups.push(() => browser.close());
|
|
151
|
+
const measured = [];
|
|
152
|
+
for (const page of pages) for (const formFactor of FORM_FACTORS) {
|
|
153
|
+
const url = new URL(page.path, server.url).href;
|
|
154
|
+
const entry = {
|
|
155
|
+
page: page.template,
|
|
156
|
+
url,
|
|
157
|
+
formFactor,
|
|
158
|
+
runs: []
|
|
159
|
+
};
|
|
160
|
+
for (let i = 1; i <= runs; i++) {
|
|
161
|
+
io.err(progress(`lighthouse ${page.template} ${formFactor} ${i}/${runs}`));
|
|
162
|
+
entry.runs.push(await browser.run(url, formFactor));
|
|
163
|
+
}
|
|
164
|
+
measured.push(entry);
|
|
165
|
+
}
|
|
166
|
+
const scored = measured.map(pageScore);
|
|
167
|
+
const avg = average(scored);
|
|
168
|
+
report.lighthouse = {
|
|
169
|
+
runs,
|
|
170
|
+
pages: scored,
|
|
171
|
+
average: avg,
|
|
172
|
+
bar: BAR
|
|
173
|
+
};
|
|
174
|
+
report.problems.push(...verdict(avg));
|
|
175
|
+
return finish();
|
|
176
|
+
} catch (err) {
|
|
177
|
+
return fail(err.message);
|
|
178
|
+
} finally {
|
|
179
|
+
process.off("SIGINT", onSignal);
|
|
180
|
+
process.off("SIGTERM", onSignal);
|
|
181
|
+
await cleanUp();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
export { USAGE, processIo, runAudit };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parseEnv } from "node:util";
|
|
5
|
+
//#region src/audit/env.ts
|
|
6
|
+
/** What the theme server reads from its environment — the starter's `.env.example`. */
|
|
7
|
+
const THEME_ENV_KEYS = [
|
|
8
|
+
"STOREFRONT_API_URL",
|
|
9
|
+
"STOREFRONT_CLIENT_ID",
|
|
10
|
+
"STOREFRONT_CLIENT_SECRET",
|
|
11
|
+
"STORE_SLUG",
|
|
12
|
+
"STOREFRONT_CUSTOMER_SESSION_SECRET"
|
|
13
|
+
];
|
|
14
|
+
var MissingEnvError = class extends Error {
|
|
15
|
+
missing;
|
|
16
|
+
files;
|
|
17
|
+
constructor(missing, files) {
|
|
18
|
+
const where = files.length ? files.join(", ") : "no .env file found";
|
|
19
|
+
super(`missing ${missing.join(", ")} (read: ${where})`);
|
|
20
|
+
this.missing = missing;
|
|
21
|
+
this.files = files;
|
|
22
|
+
this.name = "MissingEnvError";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The `.env` files `pnpm dev` reads for this theme, in precedence order: the
|
|
27
|
+
* theme's own, then — for a theme inside a pnpm workspace (this repo's
|
|
28
|
+
* `themes/*`) — the workspace root's. Same rule as `createThemeViteConfig`.
|
|
29
|
+
*/
|
|
30
|
+
function themeEnvFiles(themeDir) {
|
|
31
|
+
return [".env", ...existsSync(path.join(themeDir, "../../pnpm-workspace.yaml")) ? ["../../.env"] : []].map((f) => path.resolve(themeDir, f));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The theme server's variables only: the process environment first (CI sets
|
|
35
|
+
* them there), then the first `.env` file that sets each one. Throws naming
|
|
36
|
+
* the missing variables, never a value.
|
|
37
|
+
*/
|
|
38
|
+
function loadThemeEnv(themeDir, processEnv = process.env) {
|
|
39
|
+
const files = themeEnvFiles(themeDir).filter((f) => existsSync(f));
|
|
40
|
+
const parsed = files.map((f) => parseEnv(readFileSync(f, "utf-8")));
|
|
41
|
+
const env = {};
|
|
42
|
+
for (const key of THEME_ENV_KEYS) {
|
|
43
|
+
const value = processEnv[key] || parsed.find((p) => p[key])?.[key];
|
|
44
|
+
if (value) env[key] = value;
|
|
45
|
+
}
|
|
46
|
+
const missing = THEME_ENV_KEYS.filter((key) => !env[key]);
|
|
47
|
+
if (missing.length) throw new MissingEnvError(missing, files);
|
|
48
|
+
return {
|
|
49
|
+
env,
|
|
50
|
+
files
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The values to scrub from anything a child process printed. */
|
|
54
|
+
function secretValues(env) {
|
|
55
|
+
return [
|
|
56
|
+
env.STOREFRONT_CLIENT_ID,
|
|
57
|
+
env.STOREFRONT_CLIENT_SECRET,
|
|
58
|
+
env.STOREFRONT_CUSTOMER_SESSION_SECRET
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
function redact(text, secrets) {
|
|
62
|
+
return secrets.filter(Boolean).reduce((out, s) => out.split(s).join("[redacted]"), text);
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
export { MissingEnvError, THEME_ENV_KEYS, loadThemeEnv, redact, secretValues, themeEnvFiles };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//#region src/audit/lighthouse.ts
|
|
3
|
+
/**
|
|
4
|
+
* Headless Chrome plus Lighthouse, both loaded on first use so the rest of the
|
|
5
|
+
* CLI never pays for them. Chrome is launched once; Lighthouse resets storage
|
|
6
|
+
* before every run, so runs don't share a warm cache.
|
|
7
|
+
*/
|
|
8
|
+
async function openBrowser() {
|
|
9
|
+
const [{ launch }, { default: lighthouse, defaultConfig, desktopConfig }, constants] = await Promise.all([
|
|
10
|
+
import("chrome-launcher"),
|
|
11
|
+
import("lighthouse"),
|
|
12
|
+
import("lighthouse/core/config/constants.js")
|
|
13
|
+
]);
|
|
14
|
+
const chrome = await launch({
|
|
15
|
+
chromeFlags: [
|
|
16
|
+
"--headless=new",
|
|
17
|
+
"--no-first-run",
|
|
18
|
+
"--no-default-browser-check"
|
|
19
|
+
],
|
|
20
|
+
handleSIGINT: false,
|
|
21
|
+
logLevel: "silent"
|
|
22
|
+
});
|
|
23
|
+
const presets = {
|
|
24
|
+
mobile: defaultConfig,
|
|
25
|
+
desktop: desktopConfig
|
|
26
|
+
};
|
|
27
|
+
const config = (formFactor) => ({
|
|
28
|
+
...presets[formFactor],
|
|
29
|
+
settings: {
|
|
30
|
+
...presets[formFactor].settings,
|
|
31
|
+
formFactor,
|
|
32
|
+
screenEmulation: constants.screenEmulationMetrics[formFactor],
|
|
33
|
+
onlyCategories: ["performance", "accessibility"]
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
async run(url, formFactor) {
|
|
38
|
+
const result = await lighthouse(url, {
|
|
39
|
+
port: chrome.port,
|
|
40
|
+
logLevel: "error",
|
|
41
|
+
output: "json"
|
|
42
|
+
}, config(formFactor));
|
|
43
|
+
if (!result) throw new Error(`lighthouse returned nothing for ${url}`);
|
|
44
|
+
return scoresOf(result.lhr, formFactor);
|
|
45
|
+
},
|
|
46
|
+
async close() {
|
|
47
|
+
chrome.kill();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** A run's scores, after checking the report ran the way it was asked to. */
|
|
52
|
+
function scoresOf(lhr, formFactor) {
|
|
53
|
+
if (lhr.runtimeError) throw new Error(`lighthouse ${lhr.finalDisplayedUrl}: ${lhr.runtimeError.message}`);
|
|
54
|
+
const { screenEmulation, formFactor: ran } = lhr.configSettings;
|
|
55
|
+
if (screenEmulation.disabled || ran !== formFactor || screenEmulation.mobile !== (formFactor === "mobile")) throw new Error(`lighthouse ran without ${formFactor} screen emulation (formFactor ${ran}, emulation ${screenEmulation.disabled ? "disabled" : `${screenEmulation.width}px`})`);
|
|
56
|
+
const score = (id) => {
|
|
57
|
+
const value = lhr.categories[id]?.score;
|
|
58
|
+
if (value == null) throw new Error(`lighthouse ${lhr.finalDisplayedUrl}: no ${id} score`);
|
|
59
|
+
return Math.round(value * 100);
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
performance: score("performance"),
|
|
63
|
+
accessibility: score("accessibility")
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { openBrowser, scoresOf };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { display } from "./score.mjs";
|
|
3
|
+
import { styleText } from "node:util";
|
|
4
|
+
//#region src/audit/report.ts
|
|
5
|
+
const BAR = {
|
|
6
|
+
performance: 60,
|
|
7
|
+
accessibility: 90
|
|
8
|
+
};
|
|
9
|
+
const scoreCell = (value, bar, width) => styleText(value < bar ? "red" : "green", String(display(value)).padStart(width));
|
|
10
|
+
function formatReport(report) {
|
|
11
|
+
const out = [styleText("bold", "Routes")];
|
|
12
|
+
const label = Math.max(...report.routes.map((r) => r.template.length), 0);
|
|
13
|
+
for (const r of report.routes) {
|
|
14
|
+
const mark = r.ok ? styleText("green", "✓") : styleText("red", "✖");
|
|
15
|
+
const status = r.status === null ? styleText("red", "ERR") : String(r.status);
|
|
16
|
+
const why = r.ok ? "" : styleText("dim", ` expected ${r.expect}${r.error ? `: ${r.error}` : ""}`);
|
|
17
|
+
out.push(` ${mark} ${status} ${styleText("yellow", r.template.padEnd(label))} ${styleText("cyan", r.path)}${why}`);
|
|
18
|
+
}
|
|
19
|
+
for (const s of report.skippedRoutes) out.push(styleText("dim", ` - skipped ${s.template}: ${s.reason}`));
|
|
20
|
+
if (report.lighthouse) {
|
|
21
|
+
const { pages, average, runs } = report.lighthouse;
|
|
22
|
+
out.push("", styleText("bold", `Lighthouse (median of ${runs} run${runs === 1 ? "" : "s"})`));
|
|
23
|
+
out.push(styleText("dim", ` ${"page".padEnd(12)} ${"form".padEnd(8)} ${"perf".padStart(5)} ${"a11y".padStart(5)}`));
|
|
24
|
+
for (const p of pages) out.push(` ${styleText("yellow", p.page.padEnd(12))} ${p.formFactor.padEnd(8)} ${scoreCell(p.performance, 60, 5)} ${scoreCell(p.accessibility, 90, 5)}`);
|
|
25
|
+
out.push(` ${styleText("bold", "average".padEnd(21))} ${scoreCell(average.performance, 60, 5)} ${scoreCell(average.accessibility, 90, 5)}` + styleText("dim", ` bar: perf ≥ 60, a11y ≥ 90`));
|
|
26
|
+
}
|
|
27
|
+
out.push("");
|
|
28
|
+
if (report.ok) out.push(styleText(["green", "bold"], "✓ meets the quality bar"));
|
|
29
|
+
else {
|
|
30
|
+
for (const p of report.problems) out.push(`${styleText("red", "✖")} ${p}`);
|
|
31
|
+
const n = report.problems.length;
|
|
32
|
+
out.push(styleText(["red", "bold"], `✖ ${n} problem${n === 1 ? "" : "s"}`));
|
|
33
|
+
}
|
|
34
|
+
return out.join("\n");
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { BAR, formatReport };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//#region src/audit/routes.ts
|
|
3
|
+
/** An unknown path, so a theme that answers 200 for everything is caught. */
|
|
4
|
+
const UNKNOWN_PATH = "/shop-on-audit-no-such-page";
|
|
5
|
+
/**
|
|
6
|
+
* Every root template the starter skeleton defines, at a URL this store can
|
|
7
|
+
* fill. `password` is left out: it renders only while protection is on, and
|
|
8
|
+
* the audit refuses to run then.
|
|
9
|
+
*/
|
|
10
|
+
function auditRoutes(store) {
|
|
11
|
+
const routes = [{
|
|
12
|
+
template: "index",
|
|
13
|
+
path: "/",
|
|
14
|
+
expect: 200
|
|
15
|
+
}];
|
|
16
|
+
const skipped = [];
|
|
17
|
+
const add = (template, handle, path) => {
|
|
18
|
+
if (handle) routes.push({
|
|
19
|
+
template,
|
|
20
|
+
path: path(encodeURIComponent(handle)),
|
|
21
|
+
expect: 200
|
|
22
|
+
});
|
|
23
|
+
else skipped.push({
|
|
24
|
+
template,
|
|
25
|
+
reason: `the store has no ${template}`
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
add("product", store.product, (h) => `/products/${h}`);
|
|
29
|
+
add("collection", store.collection, (h) => `/collections/${h}`);
|
|
30
|
+
routes.push({
|
|
31
|
+
template: "list-collections",
|
|
32
|
+
path: "/collections",
|
|
33
|
+
expect: 200
|
|
34
|
+
});
|
|
35
|
+
routes.push({
|
|
36
|
+
template: "search",
|
|
37
|
+
path: `/search?q=${encodeURIComponent(store.product ?? "a")}`,
|
|
38
|
+
expect: 200
|
|
39
|
+
});
|
|
40
|
+
routes.push({
|
|
41
|
+
template: "cart",
|
|
42
|
+
path: "/cart",
|
|
43
|
+
expect: 200
|
|
44
|
+
});
|
|
45
|
+
add("page", store.page, (h) => `/pages/${h}`);
|
|
46
|
+
add("blog", store.blog, (h) => `/blogs/${h}`);
|
|
47
|
+
add("article", store.article, (h) => `/blogs/${encodeURIComponent(store.blog ?? "")}/articles/${h}`);
|
|
48
|
+
add("policy", store.policy, (h) => `/policies/${h}`);
|
|
49
|
+
routes.push({
|
|
50
|
+
template: "404",
|
|
51
|
+
path: UNKNOWN_PATH,
|
|
52
|
+
expect: 404
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
routes,
|
|
56
|
+
skipped
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Exactly the expected status passes. A redirect is a failure, not a pass: a
|
|
61
|
+
* template that bounces to `/` or `/password` never rendered.
|
|
62
|
+
*/
|
|
63
|
+
function evaluateRoute(route, outcome) {
|
|
64
|
+
if (outcome instanceof Error) return {
|
|
65
|
+
...route,
|
|
66
|
+
status: null,
|
|
67
|
+
error: outcome.message,
|
|
68
|
+
ok: false
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
...route,
|
|
72
|
+
status: outcome,
|
|
73
|
+
ok: outcome === route.expect
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
async function checkRoutes(baseUrl, routes, fetchImpl = fetch) {
|
|
77
|
+
const results = [];
|
|
78
|
+
for (const route of routes) {
|
|
79
|
+
const outcome = await fetchImpl(new URL(route.path, baseUrl), {
|
|
80
|
+
redirect: "manual",
|
|
81
|
+
signal: AbortSignal.timeout(3e4)
|
|
82
|
+
}).then(async (res) => {
|
|
83
|
+
await res.body?.cancel();
|
|
84
|
+
return res.status;
|
|
85
|
+
}, (err) => err instanceof Error ? err : new Error(String(err)));
|
|
86
|
+
results.push(evaluateRoute(route, outcome));
|
|
87
|
+
}
|
|
88
|
+
return results;
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
export { UNKNOWN_PATH, auditRoutes, checkRoutes, evaluateRoute };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The median, so one outlier run (a GC pause, a cold cache) can't move the
|
|
4
|
+
* result: a single run varies 5–10 points. An even count takes the mean of the
|
|
5
|
+
* middle two.
|
|
6
|
+
*/
|
|
7
|
+
function median(values) {
|
|
8
|
+
if (!values.length) throw new Error("median of no values");
|
|
9
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
10
|
+
const mid = Math.floor(sorted.length / 2);
|
|
11
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
12
|
+
}
|
|
13
|
+
/** Each category's median per page and form factor, taken independently. */
|
|
14
|
+
function pageScore(p) {
|
|
15
|
+
return {
|
|
16
|
+
...p,
|
|
17
|
+
performance: median(p.runs.map((r) => r.performance)),
|
|
18
|
+
accessibility: median(p.runs.map((r) => r.accessibility))
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function average(scores) {
|
|
22
|
+
if (!scores.length) throw new Error("average of no scores");
|
|
23
|
+
const mean = (pick) => scores.reduce((sum, s) => sum + pick(s), 0) / scores.length;
|
|
24
|
+
return {
|
|
25
|
+
performance: mean((s) => s.performance),
|
|
26
|
+
accessibility: mean((s) => s.accessibility)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** What falls short of the bar, one line each; empty when it clears. */
|
|
30
|
+
function verdict(avg) {
|
|
31
|
+
const failures = [];
|
|
32
|
+
if (avg.performance < 60) failures.push(`performance ${display(avg.performance)} is under 60`);
|
|
33
|
+
if (avg.accessibility < 90) failures.push(`accessibility ${display(avg.accessibility)} is under 90`);
|
|
34
|
+
return failures;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* One decimal for display, rounded DOWN: a 59.96 average fails, so it must not
|
|
38
|
+
* print as a passing 60. The verdict compares the exact mean.
|
|
39
|
+
*/
|
|
40
|
+
const display = (n) => Math.floor(n * 10 + 1e-9) / 10;
|
|
41
|
+
//#endregion
|
|
42
|
+
export { average, display, median, pageScore, verdict };
|