laxy-verify 1.1.32 → 1.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/README.md +322 -322
- package/dist/audit/broken-links.d.ts +21 -21
- package/dist/audit/broken-links.js +86 -86
- package/dist/auth.d.ts +11 -6
- package/dist/auth.js +222 -188
- package/dist/cli.js +806 -724
- package/dist/comment.d.ts +21 -21
- package/dist/comment.js +134 -131
- package/dist/crawler.d.ts +36 -35
- package/dist/crawler.js +357 -356
- package/dist/e2e.d.ts +49 -49
- package/dist/e2e.js +565 -539
- package/dist/entitlement.d.ts +11 -11
- package/dist/entitlement.js +90 -88
- package/dist/init.js +87 -87
- package/dist/multi-viewport.d.ts +31 -31
- package/dist/multi-viewport.js +298 -298
- package/dist/playwright-runner.d.ts +16 -16
- package/dist/playwright-runner.js +208 -208
- package/dist/report-markdown.d.ts +39 -39
- package/dist/report-markdown.js +386 -386
- package/dist/security-audit.d.ts +9 -9
- package/dist/security-audit.js +64 -64
- package/dist/serve.d.ts +13 -13
- package/dist/serve.js +249 -246
- package/dist/trend.d.ts +50 -49
- package/dist/trend.js +148 -147
- package/dist/verification-core/index.d.ts +3 -3
- package/dist/verification-core/index.js +19 -19
- package/dist/verification-core/report.d.ts +14 -14
- package/dist/verification-core/report.js +409 -404
- package/dist/verification-core/tier-policy.d.ts +13 -13
- package/dist/verification-core/tier-policy.js +60 -60
- package/dist/verification-core/types.d.ts +108 -108
- package/dist/verification-core/types.js +2 -2
- package/dist/visual-diff.d.ts +26 -26
- package/dist/visual-diff.js +178 -178
- package/package.json +67 -67
package/dist/multi-viewport.js
CHANGED
|
@@ -1,95 +1,95 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.runMultiViewportLighthouse = runMultiViewportLighthouse;
|
|
37
|
-
exports.printMultiViewportResults = printMultiViewportResults;
|
|
38
|
-
exports.allViewportsPass = allViewportsPass;
|
|
39
|
-
exports.runMobileLighthouse = runMobileLighthouse;
|
|
40
|
-
/**
|
|
41
|
-
* Pro+ multi-viewport Lighthouse checks.
|
|
42
|
-
*
|
|
43
|
-
* Each viewport runs through the same direct Lighthouse execution path used by
|
|
44
|
-
* the main verify flow so Windows cleanup behavior is consistent.
|
|
45
|
-
*/
|
|
46
|
-
const node_child_process_1 = require("node:child_process");
|
|
47
|
-
const fs = __importStar(require("node:fs"));
|
|
48
|
-
const path = __importStar(require("node:path"));
|
|
49
|
-
let puppeteerModule = null;
|
|
50
|
-
try {
|
|
51
|
-
puppeteerModule = require("puppeteer");
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
// Puppeteer not available — screenshot comparison will be skipped
|
|
55
|
-
}
|
|
56
|
-
const LH_NODE_MODULES_DIR = path.resolve(__dirname, "..", "node_modules");
|
|
57
|
-
const VIEWPORTS = [
|
|
58
|
-
{
|
|
59
|
-
name: "desktop",
|
|
60
|
-
formFactor: "desktop",
|
|
61
|
-
screen: { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false },
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
name: "tablet",
|
|
65
|
-
formFactor: "desktop",
|
|
66
|
-
screen: { mobile: false, width: 1024, height: 768, deviceScaleFactor: 1, disabled: false },
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
name: "mobile",
|
|
70
|
-
formFactor: "mobile",
|
|
71
|
-
screen: { mobile: true, width: 390, height: 844, deviceScaleFactor: 2, disabled: false },
|
|
72
|
-
},
|
|
73
|
-
];
|
|
74
|
-
function sleep(ms) {
|
|
75
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
|
-
}
|
|
77
|
-
async function removeDirWithRetries(dirPath, retries = 5) {
|
|
78
|
-
for (let attempt = 0; attempt < retries; attempt++) {
|
|
79
|
-
try {
|
|
80
|
-
if (fs.existsSync(dirPath)) {
|
|
81
|
-
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
82
|
-
}
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
if (attempt === retries - 1)
|
|
87
|
-
return;
|
|
88
|
-
await sleep(250);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
function writeViewportRunnerScript(runnerPath) {
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runMultiViewportLighthouse = runMultiViewportLighthouse;
|
|
37
|
+
exports.printMultiViewportResults = printMultiViewportResults;
|
|
38
|
+
exports.allViewportsPass = allViewportsPass;
|
|
39
|
+
exports.runMobileLighthouse = runMobileLighthouse;
|
|
40
|
+
/**
|
|
41
|
+
* Pro+ multi-viewport Lighthouse checks.
|
|
42
|
+
*
|
|
43
|
+
* Each viewport runs through the same direct Lighthouse execution path used by
|
|
44
|
+
* the main verify flow so Windows cleanup behavior is consistent.
|
|
45
|
+
*/
|
|
46
|
+
const node_child_process_1 = require("node:child_process");
|
|
47
|
+
const fs = __importStar(require("node:fs"));
|
|
48
|
+
const path = __importStar(require("node:path"));
|
|
49
|
+
let puppeteerModule = null;
|
|
50
|
+
try {
|
|
51
|
+
puppeteerModule = require("puppeteer");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Puppeteer not available — screenshot comparison will be skipped
|
|
55
|
+
}
|
|
56
|
+
const LH_NODE_MODULES_DIR = path.resolve(__dirname, "..", "node_modules");
|
|
57
|
+
const VIEWPORTS = [
|
|
58
|
+
{
|
|
59
|
+
name: "desktop",
|
|
60
|
+
formFactor: "desktop",
|
|
61
|
+
screen: { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "tablet",
|
|
65
|
+
formFactor: "desktop",
|
|
66
|
+
screen: { mobile: false, width: 1024, height: 768, deviceScaleFactor: 1, disabled: false },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "mobile",
|
|
70
|
+
formFactor: "mobile",
|
|
71
|
+
screen: { mobile: true, width: 390, height: 844, deviceScaleFactor: 2, disabled: false },
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
function sleep(ms) {
|
|
75
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
|
+
}
|
|
77
|
+
async function removeDirWithRetries(dirPath, retries = 5) {
|
|
78
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
79
|
+
try {
|
|
80
|
+
if (fs.existsSync(dirPath)) {
|
|
81
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
if (attempt === retries - 1)
|
|
87
|
+
return;
|
|
88
|
+
await sleep(250);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function writeViewportRunnerScript(runnerPath) {
|
|
93
93
|
const source = `"use strict";
|
|
94
94
|
const fs = require("node:fs/promises");
|
|
95
95
|
const _lhModule = require("lighthouse");
|
|
@@ -141,209 +141,209 @@ const screen = JSON.parse(screenJson);
|
|
|
141
141
|
process.stderr.write(err.message + "\\n");
|
|
142
142
|
process.exit(1);
|
|
143
143
|
});
|
|
144
|
-
`;
|
|
145
|
-
// .cjs 확장자로 저장해서 Node가 CommonJS로 실행하도록 함
|
|
146
|
-
fs.writeFileSync(runnerPath, source, "utf-8");
|
|
147
|
-
}
|
|
148
|
-
async function runLighthouseForViewport(port, viewport, tempRoot) {
|
|
149
|
-
const runnerPath = path.join(tempRoot, "run-viewport-lighthouse.cjs");
|
|
150
|
-
const reportPath = path.join(tempRoot, `${viewport.name}.json`);
|
|
151
|
-
const chromeDir = path.join(tempRoot, `chrome-${viewport.name}`);
|
|
152
|
-
writeViewportRunnerScript(runnerPath);
|
|
153
|
-
fs.mkdirSync(chromeDir, { recursive: true });
|
|
154
|
-
const child = (0, node_child_process_1.spawn)("node", [
|
|
155
|
-
runnerPath,
|
|
156
|
-
`http://127.0.0.1:${port}/`,
|
|
157
|
-
reportPath,
|
|
158
|
-
chromeDir,
|
|
159
|
-
viewport.formFactor,
|
|
160
|
-
JSON.stringify(viewport.screen),
|
|
161
|
-
], {
|
|
162
|
-
shell: false,
|
|
163
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
164
|
-
env: {
|
|
165
|
-
...process.env,
|
|
166
|
-
NODE_PATH: [LH_NODE_MODULES_DIR, process.env.NODE_PATH].filter(Boolean).join(path.delimiter),
|
|
167
|
-
TEMP: tempRoot,
|
|
168
|
-
TMP: tempRoot,
|
|
169
|
-
TMPDIR: tempRoot,
|
|
170
|
-
},
|
|
171
|
-
});
|
|
172
|
-
child.stdout?.on("data", (chunk) => {
|
|
173
|
-
const lines = chunk.toString().split("\n").filter(Boolean);
|
|
174
|
-
for (const line of lines)
|
|
175
|
-
console.log(` [lh:${viewport.name}] ${line}`);
|
|
176
|
-
});
|
|
177
|
-
child.stderr?.on("data", (chunk) => {
|
|
178
|
-
const lines = chunk.toString().split("\n").filter(Boolean);
|
|
179
|
-
for (const line of lines)
|
|
180
|
-
console.error(` [lh:${viewport.name}] ${line}`);
|
|
181
|
-
});
|
|
182
|
-
const code = await new Promise((resolve) => child.on("exit", (exitCode) => resolve(exitCode ?? 1)));
|
|
183
|
-
if (code !== 0 || !fs.existsSync(reportPath)) {
|
|
184
|
-
await removeDirWithRetries(chromeDir);
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
try {
|
|
188
|
-
const lhr = JSON.parse(fs.readFileSync(reportPath, "utf-8"));
|
|
189
|
-
return {
|
|
190
|
-
performance: Math.round((lhr.categories.performance?.score ?? 0) * 100),
|
|
191
|
-
accessibility: Math.round((lhr.categories.accessibility?.score ?? 0) * 100),
|
|
192
|
-
seo: Math.round((lhr.categories.seo?.score ?? 0) * 100),
|
|
193
|
-
bestPractices: Math.round((lhr.categories["best-practices"]?.score ?? 0) * 100),
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
finally {
|
|
200
|
-
await removeDirWithRetries(chromeDir);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
function pixelDiff(buf1, buf2) {
|
|
204
|
-
if (buf1.length !== buf2.length)
|
|
205
|
-
return 100;
|
|
206
|
-
if (buf1.length === 0)
|
|
207
|
-
return 0;
|
|
208
|
-
let diff = 0;
|
|
209
|
-
for (let i = 0; i < buf1.length; i++) {
|
|
210
|
-
if (Math.abs(buf1[i] - buf2[i]) > 10)
|
|
211
|
-
diff++;
|
|
212
|
-
}
|
|
213
|
-
return (diff / buf1.length) * 100;
|
|
214
|
-
}
|
|
215
|
-
async function captureViewportScreenshot(port, viewport) {
|
|
216
|
-
if (!puppeteerModule)
|
|
217
|
-
return null;
|
|
218
|
-
const puppeteer = puppeteerModule.default || puppeteerModule;
|
|
219
|
-
let browser;
|
|
220
|
-
try {
|
|
221
|
-
browser = await puppeteer.launch({
|
|
222
|
-
headless: true,
|
|
223
|
-
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
224
|
-
});
|
|
225
|
-
const page = await browser.newPage();
|
|
226
|
-
await page.setViewport({
|
|
227
|
-
width: viewport.screen.width,
|
|
228
|
-
height: viewport.screen.height,
|
|
229
|
-
deviceScaleFactor: viewport.screen.deviceScaleFactor,
|
|
230
|
-
isMobile: viewport.screen.mobile,
|
|
231
|
-
});
|
|
232
|
-
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: "networkidle2", timeout: 20000 });
|
|
233
|
-
await page.waitForSelector("body", { timeout: 5000 });
|
|
234
|
-
const screenshot = await page.screenshot({ type: "png" });
|
|
235
|
-
return Buffer.from(screenshot);
|
|
236
|
-
}
|
|
237
|
-
catch {
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
finally {
|
|
241
|
-
await browser?.close();
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
function getBaselineDir() {
|
|
245
|
-
return path.join(process.cwd(), ".laxy-baselines");
|
|
246
|
-
}
|
|
247
|
-
function compareWithBaseline(viewport, screenshot) {
|
|
248
|
-
const baselineDir = getBaselineDir();
|
|
249
|
-
const baselinePath = path.join(baselineDir, `${viewport.name}-baseline.png`);
|
|
250
|
-
if (!fs.existsSync(baselinePath)) {
|
|
251
|
-
fs.mkdirSync(baselineDir, { recursive: true });
|
|
252
|
-
fs.writeFileSync(baselinePath, screenshot);
|
|
253
|
-
return { viewport: viewport.name, diffPercent: 0, baselineCreated: true };
|
|
254
|
-
}
|
|
255
|
-
const baseline = fs.readFileSync(baselinePath);
|
|
256
|
-
const diff = pixelDiff(baseline, screenshot);
|
|
257
|
-
return { viewport: viewport.name, diffPercent: Math.round(diff * 100) / 100, baselineCreated: false };
|
|
258
|
-
}
|
|
259
|
-
async function runMultiViewportLighthouse(port) {
|
|
260
|
-
console.log("\n [Pro+] Running multi-viewport Lighthouse checks (desktop, tablet, mobile)...");
|
|
261
|
-
const tempRoot = path.join(process.cwd(), ".laxy-tmp", `multi-viewport-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
262
|
-
fs.mkdirSync(tempRoot, { recursive: true });
|
|
263
|
-
const results = { desktop: null, tablet: null, mobile: null };
|
|
264
|
-
const screenshotDiffs = [];
|
|
265
|
-
try {
|
|
266
|
-
for (const viewport of VIEWPORTS) {
|
|
267
|
-
console.log(`\n Viewport: ${viewport.name}`);
|
|
268
|
-
results[viewport.name] = await runLighthouseForViewport(port, viewport, tempRoot);
|
|
269
|
-
// Screenshot capture and baseline comparison
|
|
270
|
-
const screenshot = await captureViewportScreenshot(port, viewport);
|
|
271
|
-
if (screenshot) {
|
|
272
|
-
const diff = compareWithBaseline(viewport, screenshot);
|
|
273
|
-
screenshotDiffs.push(diff);
|
|
274
|
-
if (diff.baselineCreated) {
|
|
275
|
-
console.log(` Screenshot: baseline created for ${viewport.name}`);
|
|
276
|
-
}
|
|
277
|
-
else if (diff.diffPercent > 10) {
|
|
278
|
-
console.log(` Screenshot: ${viewport.name} diff ${diff.diffPercent}% (> 10% threshold)`);
|
|
279
|
-
}
|
|
280
|
-
else {
|
|
281
|
-
console.log(` Screenshot: ${viewport.name} diff ${diff.diffPercent}% OK`);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
if (screenshotDiffs.length > 0) {
|
|
286
|
-
results.screenshotDiffs = screenshotDiffs;
|
|
287
|
-
}
|
|
288
|
-
return results;
|
|
289
|
-
}
|
|
290
|
-
finally {
|
|
291
|
-
await removeDirWithRetries(tempRoot);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
function printMultiViewportResults(scores, thresholds) {
|
|
295
|
-
const check = (passed) => (passed ? "OK" : "FAIL");
|
|
296
|
-
const labels = ["desktop", "tablet", "mobile"];
|
|
297
|
-
const viewportLabel = {
|
|
298
|
-
desktop: "Desktop",
|
|
299
|
-
tablet: "Tablet",
|
|
300
|
-
mobile: "Mobile",
|
|
301
|
-
};
|
|
302
|
-
console.log("\n [Pro+] Multi-viewport results:");
|
|
303
|
-
for (const viewport of labels) {
|
|
304
|
-
const score = scores[viewport];
|
|
305
|
-
if (!score) {
|
|
306
|
-
console.log(` ${viewportLabel[viewport]}: missing`);
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
|
-
const allPassed = score.performance >= thresholds.performance &&
|
|
310
|
-
score.accessibility >= thresholds.accessibility &&
|
|
311
|
-
score.seo >= thresholds.seo &&
|
|
312
|
-
score.bestPractices >= thresholds.bestPractices;
|
|
313
|
-
console.log(` ${viewportLabel[viewport]}: P=${score.performance} A=${score.accessibility} SEO=${score.seo} BP=${score.bestPractices} ${check(allPassed)}`);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
function allViewportsPass(scores, thresholds) {
|
|
317
|
-
return ["desktop", "tablet", "mobile"].every((viewport) => {
|
|
318
|
-
const score = scores[viewport];
|
|
319
|
-
if (!score)
|
|
320
|
-
return false;
|
|
321
|
-
return (score.performance >= thresholds.performance &&
|
|
322
|
-
score.accessibility >= thresholds.accessibility &&
|
|
323
|
-
score.seo >= thresholds.seo &&
|
|
324
|
-
score.bestPractices >= thresholds.bestPractices);
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
/**
|
|
328
|
-
* Pro-tier mobile Lighthouse check — single mobile run without
|
|
329
|
-
* full multi-viewport overhead. Lets Pro users catch mobile regressions.
|
|
330
|
-
*/
|
|
331
|
-
async function runMobileLighthouse(port) {
|
|
332
|
-
console.log("\n [Pro] Running mobile Lighthouse check...");
|
|
333
|
-
const mobileViewport = VIEWPORTS.find((v) => v.name === "mobile");
|
|
334
|
-
const tempRoot = path.join(process.cwd(), ".laxy-tmp", `mobile-lh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
335
|
-
fs.mkdirSync(tempRoot, { recursive: true });
|
|
336
|
-
try {
|
|
337
|
-
const scores = await runLighthouseForViewport(port, mobileViewport, tempRoot);
|
|
338
|
-
if (scores) {
|
|
339
|
-
console.log(` [Pro] Mobile: P=${scores.performance} A=${scores.accessibility} SEO=${scores.seo} BP=${scores.bestPractices}`);
|
|
340
|
-
}
|
|
341
|
-
else {
|
|
342
|
-
console.log(" [Pro] Mobile Lighthouse: failed to collect");
|
|
343
|
-
}
|
|
344
|
-
return scores;
|
|
345
|
-
}
|
|
346
|
-
finally {
|
|
347
|
-
await removeDirWithRetries(tempRoot);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
144
|
+
`;
|
|
145
|
+
// .cjs 확장자로 저장해서 Node가 CommonJS로 실행하도록 함
|
|
146
|
+
fs.writeFileSync(runnerPath, source, "utf-8");
|
|
147
|
+
}
|
|
148
|
+
async function runLighthouseForViewport(port, viewport, tempRoot) {
|
|
149
|
+
const runnerPath = path.join(tempRoot, "run-viewport-lighthouse.cjs");
|
|
150
|
+
const reportPath = path.join(tempRoot, `${viewport.name}.json`);
|
|
151
|
+
const chromeDir = path.join(tempRoot, `chrome-${viewport.name}`);
|
|
152
|
+
writeViewportRunnerScript(runnerPath);
|
|
153
|
+
fs.mkdirSync(chromeDir, { recursive: true });
|
|
154
|
+
const child = (0, node_child_process_1.spawn)("node", [
|
|
155
|
+
runnerPath,
|
|
156
|
+
`http://127.0.0.1:${port}/`,
|
|
157
|
+
reportPath,
|
|
158
|
+
chromeDir,
|
|
159
|
+
viewport.formFactor,
|
|
160
|
+
JSON.stringify(viewport.screen),
|
|
161
|
+
], {
|
|
162
|
+
shell: false,
|
|
163
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
164
|
+
env: {
|
|
165
|
+
...process.env,
|
|
166
|
+
NODE_PATH: [LH_NODE_MODULES_DIR, process.env.NODE_PATH].filter(Boolean).join(path.delimiter),
|
|
167
|
+
TEMP: tempRoot,
|
|
168
|
+
TMP: tempRoot,
|
|
169
|
+
TMPDIR: tempRoot,
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
child.stdout?.on("data", (chunk) => {
|
|
173
|
+
const lines = chunk.toString().split("\n").filter(Boolean);
|
|
174
|
+
for (const line of lines)
|
|
175
|
+
console.log(` [lh:${viewport.name}] ${line}`);
|
|
176
|
+
});
|
|
177
|
+
child.stderr?.on("data", (chunk) => {
|
|
178
|
+
const lines = chunk.toString().split("\n").filter(Boolean);
|
|
179
|
+
for (const line of lines)
|
|
180
|
+
console.error(` [lh:${viewport.name}] ${line}`);
|
|
181
|
+
});
|
|
182
|
+
const code = await new Promise((resolve) => child.on("exit", (exitCode) => resolve(exitCode ?? 1)));
|
|
183
|
+
if (code !== 0 || !fs.existsSync(reportPath)) {
|
|
184
|
+
await removeDirWithRetries(chromeDir);
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const lhr = JSON.parse(fs.readFileSync(reportPath, "utf-8"));
|
|
189
|
+
return {
|
|
190
|
+
performance: Math.round((lhr.categories.performance?.score ?? 0) * 100),
|
|
191
|
+
accessibility: Math.round((lhr.categories.accessibility?.score ?? 0) * 100),
|
|
192
|
+
seo: Math.round((lhr.categories.seo?.score ?? 0) * 100),
|
|
193
|
+
bestPractices: Math.round((lhr.categories["best-practices"]?.score ?? 0) * 100),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
await removeDirWithRetries(chromeDir);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function pixelDiff(buf1, buf2) {
|
|
204
|
+
if (buf1.length !== buf2.length)
|
|
205
|
+
return 100;
|
|
206
|
+
if (buf1.length === 0)
|
|
207
|
+
return 0;
|
|
208
|
+
let diff = 0;
|
|
209
|
+
for (let i = 0; i < buf1.length; i++) {
|
|
210
|
+
if (Math.abs(buf1[i] - buf2[i]) > 10)
|
|
211
|
+
diff++;
|
|
212
|
+
}
|
|
213
|
+
return (diff / buf1.length) * 100;
|
|
214
|
+
}
|
|
215
|
+
async function captureViewportScreenshot(port, viewport) {
|
|
216
|
+
if (!puppeteerModule)
|
|
217
|
+
return null;
|
|
218
|
+
const puppeteer = puppeteerModule.default || puppeteerModule;
|
|
219
|
+
let browser;
|
|
220
|
+
try {
|
|
221
|
+
browser = await puppeteer.launch({
|
|
222
|
+
headless: true,
|
|
223
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
224
|
+
});
|
|
225
|
+
const page = await browser.newPage();
|
|
226
|
+
await page.setViewport({
|
|
227
|
+
width: viewport.screen.width,
|
|
228
|
+
height: viewport.screen.height,
|
|
229
|
+
deviceScaleFactor: viewport.screen.deviceScaleFactor,
|
|
230
|
+
isMobile: viewport.screen.mobile,
|
|
231
|
+
});
|
|
232
|
+
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: "networkidle2", timeout: 20000 });
|
|
233
|
+
await page.waitForSelector("body", { timeout: 5000 });
|
|
234
|
+
const screenshot = await page.screenshot({ type: "png" });
|
|
235
|
+
return Buffer.from(screenshot);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
await browser?.close();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function getBaselineDir() {
|
|
245
|
+
return path.join(process.cwd(), ".laxy-baselines");
|
|
246
|
+
}
|
|
247
|
+
function compareWithBaseline(viewport, screenshot) {
|
|
248
|
+
const baselineDir = getBaselineDir();
|
|
249
|
+
const baselinePath = path.join(baselineDir, `${viewport.name}-baseline.png`);
|
|
250
|
+
if (!fs.existsSync(baselinePath)) {
|
|
251
|
+
fs.mkdirSync(baselineDir, { recursive: true });
|
|
252
|
+
fs.writeFileSync(baselinePath, screenshot);
|
|
253
|
+
return { viewport: viewport.name, diffPercent: 0, baselineCreated: true };
|
|
254
|
+
}
|
|
255
|
+
const baseline = fs.readFileSync(baselinePath);
|
|
256
|
+
const diff = pixelDiff(baseline, screenshot);
|
|
257
|
+
return { viewport: viewport.name, diffPercent: Math.round(diff * 100) / 100, baselineCreated: false };
|
|
258
|
+
}
|
|
259
|
+
async function runMultiViewportLighthouse(port) {
|
|
260
|
+
console.log("\n [Pro+] Running multi-viewport Lighthouse checks (desktop, tablet, mobile)...");
|
|
261
|
+
const tempRoot = path.join(process.cwd(), ".laxy-tmp", `multi-viewport-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
262
|
+
fs.mkdirSync(tempRoot, { recursive: true });
|
|
263
|
+
const results = { desktop: null, tablet: null, mobile: null };
|
|
264
|
+
const screenshotDiffs = [];
|
|
265
|
+
try {
|
|
266
|
+
for (const viewport of VIEWPORTS) {
|
|
267
|
+
console.log(`\n Viewport: ${viewport.name}`);
|
|
268
|
+
results[viewport.name] = await runLighthouseForViewport(port, viewport, tempRoot);
|
|
269
|
+
// Screenshot capture and baseline comparison
|
|
270
|
+
const screenshot = await captureViewportScreenshot(port, viewport);
|
|
271
|
+
if (screenshot) {
|
|
272
|
+
const diff = compareWithBaseline(viewport, screenshot);
|
|
273
|
+
screenshotDiffs.push(diff);
|
|
274
|
+
if (diff.baselineCreated) {
|
|
275
|
+
console.log(` Screenshot: baseline created for ${viewport.name}`);
|
|
276
|
+
}
|
|
277
|
+
else if (diff.diffPercent > 10) {
|
|
278
|
+
console.log(` Screenshot: ${viewport.name} diff ${diff.diffPercent}% (> 10% threshold)`);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
console.log(` Screenshot: ${viewport.name} diff ${diff.diffPercent}% OK`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (screenshotDiffs.length > 0) {
|
|
286
|
+
results.screenshotDiffs = screenshotDiffs;
|
|
287
|
+
}
|
|
288
|
+
return results;
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
await removeDirWithRetries(tempRoot);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function printMultiViewportResults(scores, thresholds) {
|
|
295
|
+
const check = (passed) => (passed ? "OK" : "FAIL");
|
|
296
|
+
const labels = ["desktop", "tablet", "mobile"];
|
|
297
|
+
const viewportLabel = {
|
|
298
|
+
desktop: "Desktop",
|
|
299
|
+
tablet: "Tablet",
|
|
300
|
+
mobile: "Mobile",
|
|
301
|
+
};
|
|
302
|
+
console.log("\n [Pro+] Multi-viewport results:");
|
|
303
|
+
for (const viewport of labels) {
|
|
304
|
+
const score = scores[viewport];
|
|
305
|
+
if (!score) {
|
|
306
|
+
console.log(` ${viewportLabel[viewport]}: missing`);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const allPassed = score.performance >= thresholds.performance &&
|
|
310
|
+
score.accessibility >= thresholds.accessibility &&
|
|
311
|
+
score.seo >= thresholds.seo &&
|
|
312
|
+
score.bestPractices >= thresholds.bestPractices;
|
|
313
|
+
console.log(` ${viewportLabel[viewport]}: P=${score.performance} A=${score.accessibility} SEO=${score.seo} BP=${score.bestPractices} ${check(allPassed)}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function allViewportsPass(scores, thresholds) {
|
|
317
|
+
return ["desktop", "tablet", "mobile"].every((viewport) => {
|
|
318
|
+
const score = scores[viewport];
|
|
319
|
+
if (!score)
|
|
320
|
+
return false;
|
|
321
|
+
return (score.performance >= thresholds.performance &&
|
|
322
|
+
score.accessibility >= thresholds.accessibility &&
|
|
323
|
+
score.seo >= thresholds.seo &&
|
|
324
|
+
score.bestPractices >= thresholds.bestPractices);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Pro-tier mobile Lighthouse check — single mobile run without
|
|
329
|
+
* full multi-viewport overhead. Lets Pro users catch mobile regressions.
|
|
330
|
+
*/
|
|
331
|
+
async function runMobileLighthouse(port) {
|
|
332
|
+
console.log("\n [Pro] Running mobile Lighthouse check...");
|
|
333
|
+
const mobileViewport = VIEWPORTS.find((v) => v.name === "mobile");
|
|
334
|
+
const tempRoot = path.join(process.cwd(), ".laxy-tmp", `mobile-lh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
335
|
+
fs.mkdirSync(tempRoot, { recursive: true });
|
|
336
|
+
try {
|
|
337
|
+
const scores = await runLighthouseForViewport(port, mobileViewport, tempRoot);
|
|
338
|
+
if (scores) {
|
|
339
|
+
console.log(` [Pro] Mobile: P=${scores.performance} A=${scores.accessibility} SEO=${scores.seo} BP=${scores.bestPractices}`);
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
console.log(" [Pro] Mobile Lighthouse: failed to collect");
|
|
343
|
+
}
|
|
344
|
+
return scores;
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
await removeDirWithRetries(tempRoot);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Playwright-based E2E runner for cross-browser testing.
|
|
3
|
-
* Only activated when browsers other than "chromium" are configured.
|
|
4
|
-
* Falls back gracefully if playwright is not installed.
|
|
5
|
-
*/
|
|
6
|
-
import type { E2EScenario, E2EScenarioResult } from "./e2e.js";
|
|
7
|
-
export declare function isPlaywrightAvailable(): Promise<boolean>;
|
|
8
|
-
export type BrowserName = "chromium" | "firefox" | "webkit";
|
|
9
|
-
export interface CrossBrowserResult {
|
|
10
|
-
browser: BrowserName;
|
|
11
|
-
results: E2EScenarioResult[];
|
|
12
|
-
passed: number;
|
|
13
|
-
failed: number;
|
|
14
|
-
consoleErrors: string[];
|
|
15
|
-
}
|
|
16
|
-
export declare function runPlaywrightE2E(url: string, scenarios: E2EScenario[], browsers: BrowserName[]): Promise<CrossBrowserResult[]>;
|
|
1
|
+
/**
|
|
2
|
+
* Playwright-based E2E runner for cross-browser testing.
|
|
3
|
+
* Only activated when browsers other than "chromium" are configured.
|
|
4
|
+
* Falls back gracefully if playwright is not installed.
|
|
5
|
+
*/
|
|
6
|
+
import type { E2EScenario, E2EScenarioResult } from "./e2e.js";
|
|
7
|
+
export declare function isPlaywrightAvailable(): Promise<boolean>;
|
|
8
|
+
export type BrowserName = "chromium" | "firefox" | "webkit";
|
|
9
|
+
export interface CrossBrowserResult {
|
|
10
|
+
browser: BrowserName;
|
|
11
|
+
results: E2EScenarioResult[];
|
|
12
|
+
passed: number;
|
|
13
|
+
failed: number;
|
|
14
|
+
consoleErrors: string[];
|
|
15
|
+
}
|
|
16
|
+
export declare function runPlaywrightE2E(url: string, scenarios: E2EScenario[], browsers: BrowserName[]): Promise<CrossBrowserResult[]>;
|