@sudobility/testomniac_runner 0.0.128
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/.dockerignore +75 -0
- package/.env.example +67 -0
- package/.github/workflows/ci-cd.yml +30 -0
- package/.prettierignore +62 -0
- package/.prettierrc +11 -0
- package/.vscode/settings.json +29 -0
- package/CLAUDE.md +170 -0
- package/Dockerfile +76 -0
- package/README.md +22 -0
- package/bun.lock +707 -0
- package/docs/superpowers/specs/2026-04-20-smarter-scanner-navigation-design.md +121 -0
- package/eslint.config.js +80 -0
- package/package.json +55 -0
- package/plans/DATA.md +703 -0
- package/plans/POLLING.md +569 -0
- package/plans/RUNNER.md +288 -0
- package/src/adapters/PuppeteerAdapter.ts +394 -0
- package/src/auth/credential-manager.ts +17 -0
- package/src/auth/form-identifier.test.ts +136 -0
- package/src/auth/form-identifier.ts +54 -0
- package/src/auth/login-executor.ts +112 -0
- package/src/auth/password-detector.test.ts +61 -0
- package/src/auth/password-detector.ts +119 -0
- package/src/auth/signic-registrar.ts +186 -0
- package/src/browser/chromium.ts +35 -0
- package/src/config/index.test.ts +23 -0
- package/src/config/index.ts +35 -0
- package/src/email/deep-link.test.ts +17 -0
- package/src/email/deep-link.ts +23 -0
- package/src/email/sender.ts +35 -0
- package/src/email/templates.ts +34 -0
- package/src/index.test.ts +17 -0
- package/src/index.ts +110 -0
- package/src/orchestrator.ts +220 -0
- package/src/plugins/content/ai-checks.ts +115 -0
- package/src/plugins/content/checks.test.ts +49 -0
- package/src/plugins/content/checks.ts +141 -0
- package/src/plugins/content/index.ts +73 -0
- package/src/plugins/registry.test.ts +49 -0
- package/src/plugins/registry.ts +21 -0
- package/src/plugins/security/header-checks.ts +56 -0
- package/src/plugins/security/html-checks.ts +93 -0
- package/src/plugins/security/index.ts +58 -0
- package/src/plugins/security/network-checks.test.ts +74 -0
- package/src/plugins/security/network-checks.ts +136 -0
- package/src/plugins/seo/checks.test.ts +70 -0
- package/src/plugins/seo/checks.ts +173 -0
- package/src/plugins/seo/index.ts +85 -0
- package/src/plugins/types.ts +43 -0
- package/src/plugins/ui-consistency/comparator.test.ts +108 -0
- package/src/plugins/ui-consistency/comparator.ts +58 -0
- package/src/plugins/ui-consistency/index.ts +36 -0
- package/src/plugins/ui-consistency/style-extractor.ts +79 -0
- package/src/runner/executor.test.ts +37 -0
- package/src/runner/executor.ts +167 -0
- package/src/runner/reporter.ts +19 -0
- package/src/runner/worker-pool.ts +106 -0
- package/src/runner-manager.ts +163 -0
- package/src/scanner/email-checker.ts +106 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
checkTitle,
|
|
4
|
+
checkMetaDescription,
|
|
5
|
+
checkH1,
|
|
6
|
+
checkImgAlt,
|
|
7
|
+
checkViewportMeta,
|
|
8
|
+
} from "./checks";
|
|
9
|
+
|
|
10
|
+
const PAGE_URL = "https://example.com";
|
|
11
|
+
|
|
12
|
+
describe("SEO checks", () => {
|
|
13
|
+
it("flags missing title", () => {
|
|
14
|
+
const html = "<html><head></head><body></body></html>";
|
|
15
|
+
const issue = checkTitle(html, PAGE_URL);
|
|
16
|
+
expect(issue).not.toBeNull();
|
|
17
|
+
expect(issue!.type).toBe("seo-missing-title");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("passes valid title", () => {
|
|
21
|
+
const html = "<html><head><title>My Page</title></head></html>";
|
|
22
|
+
const issue = checkTitle(html, PAGE_URL);
|
|
23
|
+
expect(issue).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("flags title too long", () => {
|
|
27
|
+
const longTitle = "A".repeat(61);
|
|
28
|
+
const html = `<html><head><title>${longTitle}</title></head></html>`;
|
|
29
|
+
const issue = checkTitle(html, PAGE_URL);
|
|
30
|
+
expect(issue).not.toBeNull();
|
|
31
|
+
expect(issue!.type).toBe("seo-title-too-long");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("flags missing meta description", () => {
|
|
35
|
+
const html = "<html><head></head><body></body></html>";
|
|
36
|
+
const issue = checkMetaDescription(html, PAGE_URL);
|
|
37
|
+
expect(issue).not.toBeNull();
|
|
38
|
+
expect(issue!.type).toBe("seo-missing-meta-description");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("flags missing h1", () => {
|
|
42
|
+
const html = "<html><body><h2>Hello</h2></body></html>";
|
|
43
|
+
const issue = checkH1(html, PAGE_URL);
|
|
44
|
+
expect(issue).not.toBeNull();
|
|
45
|
+
expect(issue!.type).toBe("seo-missing-h1");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("flags multiple h1 tags", () => {
|
|
49
|
+
const html = "<html><body><h1>First</h1><h1>Second</h1></body></html>";
|
|
50
|
+
const issue = checkH1(html, PAGE_URL);
|
|
51
|
+
expect(issue).not.toBeNull();
|
|
52
|
+
expect(issue!.type).toBe("seo-multiple-h1");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("flags img without alt attribute", () => {
|
|
56
|
+
const html =
|
|
57
|
+
'<html><body><img src="photo.jpg"><img src="logo.png" alt="Logo"></body></html>';
|
|
58
|
+
const issues = checkImgAlt(html, PAGE_URL);
|
|
59
|
+
expect(issues).toHaveLength(1);
|
|
60
|
+
expect(issues[0].type).toBe("seo-img-missing-alt");
|
|
61
|
+
expect(issues[0].details?.src).toBe("photo.jpg");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("flags missing viewport meta tag", () => {
|
|
65
|
+
const html = "<html><head></head><body></body></html>";
|
|
66
|
+
const issue = checkViewportMeta(html, PAGE_URL);
|
|
67
|
+
expect(issue).not.toBeNull();
|
|
68
|
+
expect(issue!.type).toBe("seo-missing-viewport");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { PluginIssue } from "../types";
|
|
2
|
+
|
|
3
|
+
export function checkTitle(html: string, pageUrl: string): PluginIssue | null {
|
|
4
|
+
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
5
|
+
if (!match || !match[1].trim()) {
|
|
6
|
+
return {
|
|
7
|
+
type: "seo-missing-title",
|
|
8
|
+
severity: "warning",
|
|
9
|
+
description: "Page is missing a <title> tag",
|
|
10
|
+
pageUrl,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const title = match[1].trim();
|
|
14
|
+
if (title.length > 60) {
|
|
15
|
+
return {
|
|
16
|
+
type: "seo-title-too-long",
|
|
17
|
+
severity: "info",
|
|
18
|
+
description: `Title is too long (${title.length} chars, recommended max 60)`,
|
|
19
|
+
pageUrl,
|
|
20
|
+
details: { title, length: title.length },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function checkMetaDescription(
|
|
27
|
+
html: string,
|
|
28
|
+
pageUrl: string
|
|
29
|
+
): PluginIssue | null {
|
|
30
|
+
const match = html.match(
|
|
31
|
+
/<meta\s+[^>]*name=["']description["'][^>]*content=["']([^"']*)["'][^>]*\/?>/i
|
|
32
|
+
);
|
|
33
|
+
const match2 = html.match(
|
|
34
|
+
/<meta\s+[^>]*content=["']([^"']*)["'][^>]*name=["']description["'][^>]*\/?>/i
|
|
35
|
+
);
|
|
36
|
+
const content = match?.[1] ?? match2?.[1];
|
|
37
|
+
|
|
38
|
+
if (content === undefined) {
|
|
39
|
+
return {
|
|
40
|
+
type: "seo-missing-meta-description",
|
|
41
|
+
severity: "warning",
|
|
42
|
+
description: "Page is missing a meta description",
|
|
43
|
+
pageUrl,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (content.length > 160) {
|
|
47
|
+
return {
|
|
48
|
+
type: "seo-meta-description-too-long",
|
|
49
|
+
severity: "info",
|
|
50
|
+
description: `Meta description is too long (${content.length} chars, recommended max 160)`,
|
|
51
|
+
pageUrl,
|
|
52
|
+
details: { length: content.length },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function checkH1(html: string, pageUrl: string): PluginIssue | null {
|
|
59
|
+
const h1Matches = html.match(/<h1[\s>]/gi);
|
|
60
|
+
if (!h1Matches || h1Matches.length === 0) {
|
|
61
|
+
return {
|
|
62
|
+
type: "seo-missing-h1",
|
|
63
|
+
severity: "warning",
|
|
64
|
+
description: "Page is missing an <h1> tag",
|
|
65
|
+
pageUrl,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (h1Matches.length > 1) {
|
|
69
|
+
return {
|
|
70
|
+
type: "seo-multiple-h1",
|
|
71
|
+
severity: "warning",
|
|
72
|
+
description: `Page has ${h1Matches.length} <h1> tags (recommended: 1)`,
|
|
73
|
+
pageUrl,
|
|
74
|
+
details: { count: h1Matches.length },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function checkImgAlt(html: string, pageUrl: string): PluginIssue[] {
|
|
81
|
+
const issues: PluginIssue[] = [];
|
|
82
|
+
const imgRegex = /<img\s[^>]*>/gi;
|
|
83
|
+
let imgMatch = imgRegex.exec(html);
|
|
84
|
+
|
|
85
|
+
while (imgMatch !== null) {
|
|
86
|
+
const imgTag = imgMatch[0];
|
|
87
|
+
const hasAlt = /\balt\s*=\s*["'][^"']*["']/i.test(imgTag);
|
|
88
|
+
if (!hasAlt) {
|
|
89
|
+
const srcMatch = imgTag.match(/\bsrc\s*=\s*["']([^"']*)["']/i);
|
|
90
|
+
issues.push({
|
|
91
|
+
type: "seo-img-missing-alt",
|
|
92
|
+
severity: "warning",
|
|
93
|
+
description: "Image is missing alt attribute",
|
|
94
|
+
pageUrl,
|
|
95
|
+
details: { src: srcMatch?.[1] ?? "unknown" },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
imgMatch = imgRegex.exec(html);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return issues;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function checkCanonical(
|
|
105
|
+
html: string,
|
|
106
|
+
pageUrl: string
|
|
107
|
+
): PluginIssue | null {
|
|
108
|
+
const hasCanonical = /<link\s[^>]*rel=["']canonical["'][^>]*>/i.test(html);
|
|
109
|
+
if (!hasCanonical) {
|
|
110
|
+
return {
|
|
111
|
+
type: "seo-missing-canonical",
|
|
112
|
+
severity: "info",
|
|
113
|
+
description: "Page is missing a canonical link",
|
|
114
|
+
pageUrl,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function checkViewportMeta(
|
|
121
|
+
html: string,
|
|
122
|
+
pageUrl: string
|
|
123
|
+
): PluginIssue | null {
|
|
124
|
+
const hasViewport = /<meta\s[^>]*name=["']viewport["'][^>]*>/i.test(html);
|
|
125
|
+
if (!hasViewport) {
|
|
126
|
+
return {
|
|
127
|
+
type: "seo-missing-viewport",
|
|
128
|
+
severity: "warning",
|
|
129
|
+
description: "Page is missing a viewport meta tag",
|
|
130
|
+
pageUrl,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function checkOpenGraph(html: string, pageUrl: string): PluginIssue[] {
|
|
137
|
+
const issues: PluginIssue[] = [];
|
|
138
|
+
const ogTags = ["og:title", "og:description", "og:image"];
|
|
139
|
+
|
|
140
|
+
for (const tag of ogTags) {
|
|
141
|
+
const regex = new RegExp(
|
|
142
|
+
`<meta\\s[^>]*property=["']${tag}["'][^>]*/?>`,
|
|
143
|
+
"i"
|
|
144
|
+
);
|
|
145
|
+
if (!regex.test(html)) {
|
|
146
|
+
issues.push({
|
|
147
|
+
type: `seo-missing-${tag.replace(":", "-")}`,
|
|
148
|
+
severity: "info",
|
|
149
|
+
description: `Page is missing Open Graph ${tag} meta tag`,
|
|
150
|
+
pageUrl,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return issues;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function checkStructuredData(
|
|
159
|
+
html: string,
|
|
160
|
+
pageUrl: string
|
|
161
|
+
): PluginIssue | null {
|
|
162
|
+
const hasJsonLd =
|
|
163
|
+
/<script\s[^>]*type=["']application\/ld\+json["'][^>]*>/i.test(html);
|
|
164
|
+
if (!hasJsonLd) {
|
|
165
|
+
return {
|
|
166
|
+
type: "seo-missing-structured-data",
|
|
167
|
+
severity: "info",
|
|
168
|
+
description: "Page is missing JSON-LD structured data",
|
|
169
|
+
pageUrl,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Plugin, PluginContext, PluginIssue } from "../types";
|
|
2
|
+
import { registerPlugin } from "../registry";
|
|
3
|
+
import {
|
|
4
|
+
checkTitle,
|
|
5
|
+
checkMetaDescription,
|
|
6
|
+
checkH1,
|
|
7
|
+
checkImgAlt,
|
|
8
|
+
checkCanonical,
|
|
9
|
+
checkViewportMeta,
|
|
10
|
+
checkOpenGraph,
|
|
11
|
+
checkStructuredData,
|
|
12
|
+
} from "./checks";
|
|
13
|
+
|
|
14
|
+
export const seoPlugin: Plugin = {
|
|
15
|
+
name: "seo",
|
|
16
|
+
description:
|
|
17
|
+
"Checks pages for SEO best practices including titles, meta descriptions, headings, images, and structured data",
|
|
18
|
+
|
|
19
|
+
async analyze(context: PluginContext): Promise<{
|
|
20
|
+
issues: PluginIssue[];
|
|
21
|
+
metadata?: Record<string, unknown>;
|
|
22
|
+
}> {
|
|
23
|
+
const issues: PluginIssue[] = [];
|
|
24
|
+
const titles: Map<string, string[]> = new Map();
|
|
25
|
+
|
|
26
|
+
for (const pageState of context.pageStates) {
|
|
27
|
+
const { html, url } = pageState;
|
|
28
|
+
|
|
29
|
+
// Title check + collect for duplicate detection
|
|
30
|
+
const titleIssue = checkTitle(html, url);
|
|
31
|
+
if (titleIssue) {
|
|
32
|
+
issues.push(titleIssue);
|
|
33
|
+
} else {
|
|
34
|
+
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
35
|
+
if (titleMatch) {
|
|
36
|
+
const title = titleMatch[1].trim();
|
|
37
|
+
const urls = titles.get(title) ?? [];
|
|
38
|
+
urls.push(url);
|
|
39
|
+
titles.set(title, urls);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const metaIssue = checkMetaDescription(html, url);
|
|
44
|
+
if (metaIssue) issues.push(metaIssue);
|
|
45
|
+
|
|
46
|
+
const h1Issue = checkH1(html, url);
|
|
47
|
+
if (h1Issue) issues.push(h1Issue);
|
|
48
|
+
|
|
49
|
+
const imgIssues = checkImgAlt(html, url);
|
|
50
|
+
issues.push(...imgIssues);
|
|
51
|
+
|
|
52
|
+
const canonicalIssue = checkCanonical(html, url);
|
|
53
|
+
if (canonicalIssue) issues.push(canonicalIssue);
|
|
54
|
+
|
|
55
|
+
const viewportIssue = checkViewportMeta(html, url);
|
|
56
|
+
if (viewportIssue) issues.push(viewportIssue);
|
|
57
|
+
|
|
58
|
+
const ogIssues = checkOpenGraph(html, url);
|
|
59
|
+
issues.push(...ogIssues);
|
|
60
|
+
|
|
61
|
+
const structuredDataIssue = checkStructuredData(html, url);
|
|
62
|
+
if (structuredDataIssue) issues.push(structuredDataIssue);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Duplicate title detection across pages
|
|
66
|
+
for (const [title, urls] of titles.entries()) {
|
|
67
|
+
if (urls.length > 1) {
|
|
68
|
+
issues.push({
|
|
69
|
+
type: "seo-duplicate-title",
|
|
70
|
+
severity: "warning",
|
|
71
|
+
description: `Duplicate title "${title}" found on ${urls.length} pages`,
|
|
72
|
+
pageUrl: urls[0],
|
|
73
|
+
details: { title, urls },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
issues,
|
|
80
|
+
metadata: { pagesAnalyzed: context.pageStates.length },
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
registerPlugin(seoPlugin);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Page } from "puppeteer-core";
|
|
2
|
+
import type { OpenAI } from "openai";
|
|
3
|
+
import type { NetworkLogEntry, FormInfo } from "@sudobility/testomniac_types";
|
|
4
|
+
|
|
5
|
+
export interface PluginContext {
|
|
6
|
+
runnerId: number;
|
|
7
|
+
runId: number;
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
pages: { id: number; url: string }[];
|
|
10
|
+
pageStates: {
|
|
11
|
+
id: number;
|
|
12
|
+
pageId: number;
|
|
13
|
+
html: string;
|
|
14
|
+
text: string;
|
|
15
|
+
url: string;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
}[];
|
|
18
|
+
networkLogs: NetworkLogEntry[];
|
|
19
|
+
forms: FormInfo[];
|
|
20
|
+
openai?: OpenAI;
|
|
21
|
+
browser: Page;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PluginIssue {
|
|
25
|
+
type: string;
|
|
26
|
+
severity: "error" | "warning" | "info";
|
|
27
|
+
description: string;
|
|
28
|
+
pageUrl: string;
|
|
29
|
+
pageId?: number;
|
|
30
|
+
pageStateId?: number;
|
|
31
|
+
details?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PluginResult {
|
|
35
|
+
issues: PluginIssue[];
|
|
36
|
+
metadata?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Plugin {
|
|
40
|
+
name: string;
|
|
41
|
+
description: string;
|
|
42
|
+
analyze(context: PluginContext): Promise<PluginResult>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { findStyleDeviations } from "./comparator";
|
|
3
|
+
|
|
4
|
+
describe("UI consistency comparator", () => {
|
|
5
|
+
it("detects font inconsistency across pages", () => {
|
|
6
|
+
const styles = [
|
|
7
|
+
{
|
|
8
|
+
pageUrl: "/home",
|
|
9
|
+
category: "headings",
|
|
10
|
+
tag: "h1",
|
|
11
|
+
styles: {
|
|
12
|
+
fontFamily: "Arial",
|
|
13
|
+
fontSize: "32px",
|
|
14
|
+
fontWeight: "700",
|
|
15
|
+
color: "#000",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
pageUrl: "/about",
|
|
20
|
+
category: "headings",
|
|
21
|
+
tag: "h1",
|
|
22
|
+
styles: {
|
|
23
|
+
fontFamily: "Arial",
|
|
24
|
+
fontSize: "32px",
|
|
25
|
+
fontWeight: "700",
|
|
26
|
+
color: "#000",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
pageUrl: "/contact",
|
|
31
|
+
category: "headings",
|
|
32
|
+
tag: "h1",
|
|
33
|
+
styles: {
|
|
34
|
+
fontFamily: "Helvetica",
|
|
35
|
+
fontSize: "28px",
|
|
36
|
+
fontWeight: "700",
|
|
37
|
+
color: "#000",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
const deviations = findStyleDeviations(styles);
|
|
42
|
+
expect(deviations.length).toBeGreaterThan(0);
|
|
43
|
+
expect(deviations[0].pageUrl).toBe("/contact");
|
|
44
|
+
expect(deviations[0].details!.property).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("reports no deviations when consistent", () => {
|
|
48
|
+
const styles = [
|
|
49
|
+
{
|
|
50
|
+
pageUrl: "/home",
|
|
51
|
+
category: "buttons",
|
|
52
|
+
tag: "button",
|
|
53
|
+
styles: { backgroundColor: "#2563EB", fontSize: "14px" },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
pageUrl: "/about",
|
|
57
|
+
category: "buttons",
|
|
58
|
+
tag: "button",
|
|
59
|
+
styles: { backgroundColor: "#2563EB", fontSize: "14px" },
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
const deviations = findStyleDeviations(styles);
|
|
63
|
+
expect(deviations).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("detects button color inconsistency", () => {
|
|
67
|
+
const styles = [
|
|
68
|
+
{
|
|
69
|
+
pageUrl: "/home",
|
|
70
|
+
category: "buttons",
|
|
71
|
+
tag: "button",
|
|
72
|
+
styles: {
|
|
73
|
+
backgroundColor: "#2563EB",
|
|
74
|
+
fontSize: "14px",
|
|
75
|
+
borderRadius: "4px",
|
|
76
|
+
padding: "8px 16px",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
pageUrl: "/about",
|
|
81
|
+
category: "buttons",
|
|
82
|
+
tag: "button",
|
|
83
|
+
styles: {
|
|
84
|
+
backgroundColor: "#2563EB",
|
|
85
|
+
fontSize: "14px",
|
|
86
|
+
borderRadius: "4px",
|
|
87
|
+
padding: "8px 16px",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
pageUrl: "/checkout",
|
|
92
|
+
category: "buttons",
|
|
93
|
+
tag: "button",
|
|
94
|
+
styles: {
|
|
95
|
+
backgroundColor: "#FF0000",
|
|
96
|
+
fontSize: "14px",
|
|
97
|
+
borderRadius: "4px",
|
|
98
|
+
padding: "8px 16px",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
const deviations = findStyleDeviations(styles);
|
|
103
|
+
expect(deviations.length).toBeGreaterThan(0);
|
|
104
|
+
expect(
|
|
105
|
+
deviations.some(d => d.details!.property === "backgroundColor")
|
|
106
|
+
).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { PluginIssue } from "../types";
|
|
2
|
+
import type { ExtractedStyle } from "./style-extractor";
|
|
3
|
+
|
|
4
|
+
export function findStyleDeviations(
|
|
5
|
+
allStyles: ExtractedStyle[]
|
|
6
|
+
): PluginIssue[] {
|
|
7
|
+
const issues: PluginIssue[] = [];
|
|
8
|
+
const groups = new Map<string, ExtractedStyle[]>();
|
|
9
|
+
for (const s of allStyles) {
|
|
10
|
+
const key = `${s.category}:${s.tag}`;
|
|
11
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
12
|
+
groups.get(key)!.push(s);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (const [key, entries] of groups) {
|
|
16
|
+
if (entries.length < 2) continue;
|
|
17
|
+
const propNames = Object.keys(entries[0].styles);
|
|
18
|
+
for (const prop of propNames) {
|
|
19
|
+
const valueCounts = new Map<string, string[]>();
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const val = entry.styles[prop] || "";
|
|
22
|
+
if (!valueCounts.has(val)) valueCounts.set(val, []);
|
|
23
|
+
valueCounts.get(val)!.push(entry.pageUrl);
|
|
24
|
+
}
|
|
25
|
+
if (valueCounts.size <= 1) continue;
|
|
26
|
+
|
|
27
|
+
let majorityValue = "";
|
|
28
|
+
let majorityCount = 0;
|
|
29
|
+
for (const [val, pages] of valueCounts) {
|
|
30
|
+
if (pages.length > majorityCount) {
|
|
31
|
+
majorityCount = pages.length;
|
|
32
|
+
majorityValue = val;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const [val, pages] of valueCounts) {
|
|
37
|
+
if (val === majorityValue) continue;
|
|
38
|
+
for (const pageUrl of pages) {
|
|
39
|
+
const [category, tag] = key.split(":");
|
|
40
|
+
issues.push({
|
|
41
|
+
type: `ui_inconsistent_${category}`,
|
|
42
|
+
severity: "warning",
|
|
43
|
+
description: `${tag} on ${pageUrl} uses ${prop}: ${val}, but ${majorityCount}/${entries.length} pages use ${majorityValue}`,
|
|
44
|
+
pageUrl,
|
|
45
|
+
details: {
|
|
46
|
+
property: prop,
|
|
47
|
+
actual: val,
|
|
48
|
+
expected: majorityValue,
|
|
49
|
+
category,
|
|
50
|
+
tag,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return issues;
|
|
58
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Plugin, PluginContext, PluginResult } from "../types";
|
|
2
|
+
import { registerPlugin } from "../registry";
|
|
3
|
+
import { extractStyles, type ExtractedStyle } from "./style-extractor";
|
|
4
|
+
import { findStyleDeviations } from "./comparator";
|
|
5
|
+
|
|
6
|
+
const uiConsistencyPlugin: Plugin = {
|
|
7
|
+
name: "ui-consistency",
|
|
8
|
+
description:
|
|
9
|
+
"Detects visual style inconsistencies across pages (fonts, colors, spacing)",
|
|
10
|
+
|
|
11
|
+
async analyze(context: PluginContext): Promise<PluginResult> {
|
|
12
|
+
const allStyles: ExtractedStyle[] = [];
|
|
13
|
+
|
|
14
|
+
for (const pageState of context.pageStates) {
|
|
15
|
+
await context.browser.goto(pageState.url, {
|
|
16
|
+
waitUntil: "domcontentloaded",
|
|
17
|
+
});
|
|
18
|
+
const styles = await extractStyles(context.browser, pageState.url);
|
|
19
|
+
allStyles.push(...styles);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const issues = findStyleDeviations(allStyles);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
issues,
|
|
26
|
+
metadata: {
|
|
27
|
+
totalElementsChecked: allStyles.length,
|
|
28
|
+
pagesAnalyzed: context.pageStates.length,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
registerPlugin(uiConsistencyPlugin);
|
|
35
|
+
|
|
36
|
+
export { uiConsistencyPlugin };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Page } from "puppeteer-core";
|
|
2
|
+
|
|
3
|
+
export interface ExtractedStyle {
|
|
4
|
+
pageUrl: string;
|
|
5
|
+
category: string;
|
|
6
|
+
tag: string;
|
|
7
|
+
styles: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const STYLE_TARGETS: Record<string, { selectors: string[]; props: string[] }> =
|
|
11
|
+
{
|
|
12
|
+
headings: {
|
|
13
|
+
selectors: ["h1", "h2", "h3"],
|
|
14
|
+
props: ["fontFamily", "fontSize", "fontWeight", "color", "lineHeight"],
|
|
15
|
+
},
|
|
16
|
+
bodyText: {
|
|
17
|
+
selectors: ["p", "li"],
|
|
18
|
+
props: ["fontFamily", "fontSize", "color", "lineHeight"],
|
|
19
|
+
},
|
|
20
|
+
buttons: {
|
|
21
|
+
selectors: ["button", '[role="button"]', 'input[type="submit"]'],
|
|
22
|
+
props: [
|
|
23
|
+
"fontFamily",
|
|
24
|
+
"fontSize",
|
|
25
|
+
"fontWeight",
|
|
26
|
+
"color",
|
|
27
|
+
"backgroundColor",
|
|
28
|
+
"borderRadius",
|
|
29
|
+
"padding",
|
|
30
|
+
"height",
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
links: {
|
|
34
|
+
selectors: ["a"],
|
|
35
|
+
props: ["fontFamily", "fontSize", "color", "textDecoration"],
|
|
36
|
+
},
|
|
37
|
+
inputs: {
|
|
38
|
+
selectors: ['input[type="text"]', 'input[type="email"]', "textarea"],
|
|
39
|
+
props: [
|
|
40
|
+
"fontFamily",
|
|
41
|
+
"fontSize",
|
|
42
|
+
"borderColor",
|
|
43
|
+
"borderRadius",
|
|
44
|
+
"padding",
|
|
45
|
+
"height",
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export async function extractStyles(
|
|
51
|
+
page: Page,
|
|
52
|
+
pageUrl: string
|
|
53
|
+
): Promise<ExtractedStyle[]> {
|
|
54
|
+
const results: ExtractedStyle[] = [];
|
|
55
|
+
for (const [category, config] of Object.entries(STYLE_TARGETS)) {
|
|
56
|
+
for (const selector of config.selectors) {
|
|
57
|
+
const styles = await page.evaluate(
|
|
58
|
+
(sel: string, props: string[]) => {
|
|
59
|
+
const el = document.querySelector(sel);
|
|
60
|
+
if (!el) return null;
|
|
61
|
+
const computed = window.getComputedStyle(el);
|
|
62
|
+
const result: Record<string, string> = {};
|
|
63
|
+
for (const prop of props) {
|
|
64
|
+
result[prop] = computed.getPropertyValue(
|
|
65
|
+
prop.replace(/([A-Z])/g, "-$1").toLowerCase()
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
},
|
|
70
|
+
selector,
|
|
71
|
+
config.props
|
|
72
|
+
);
|
|
73
|
+
if (styles) {
|
|
74
|
+
results.push({ pageUrl, category, tag: selector, styles });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return results;
|
|
79
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mapActionToPuppeteer } from "./executor";
|
|
3
|
+
|
|
4
|
+
describe("executor", () => {
|
|
5
|
+
it("maps navigate action", () => {
|
|
6
|
+
const mapped = mapActionToPuppeteer({
|
|
7
|
+
action: "navigate",
|
|
8
|
+
url: "https://example.com",
|
|
9
|
+
});
|
|
10
|
+
expect(mapped.method).toBe("goto");
|
|
11
|
+
expect(mapped.args).toEqual([
|
|
12
|
+
"https://example.com",
|
|
13
|
+
{ waitUntil: "networkidle0" },
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("maps click action", () => {
|
|
18
|
+
const mapped = mapActionToPuppeteer({ action: "click", selector: "#btn" });
|
|
19
|
+
expect(mapped.method).toBe("click");
|
|
20
|
+
expect(mapped.selector).toBe("#btn");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("maps assertVisible action", () => {
|
|
24
|
+
const mapped = mapActionToPuppeteer({
|
|
25
|
+
action: "assertVisible",
|
|
26
|
+
selector: ".header",
|
|
27
|
+
});
|
|
28
|
+
expect(mapped.method).toBe("waitForSelector");
|
|
29
|
+
expect(mapped.selector).toBe(".header");
|
|
30
|
+
expect(mapped.args).toEqual([".header", { visible: true, timeout: 5000 }]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("maps step as noop", () => {
|
|
34
|
+
const mapped = mapActionToPuppeteer({ action: "step", label: "Step 1" });
|
|
35
|
+
expect(mapped.method).toBe("noop");
|
|
36
|
+
});
|
|
37
|
+
});
|