@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.
Files changed (60) hide show
  1. package/.dockerignore +75 -0
  2. package/.env.example +67 -0
  3. package/.github/workflows/ci-cd.yml +30 -0
  4. package/.prettierignore +62 -0
  5. package/.prettierrc +11 -0
  6. package/.vscode/settings.json +29 -0
  7. package/CLAUDE.md +170 -0
  8. package/Dockerfile +76 -0
  9. package/README.md +22 -0
  10. package/bun.lock +707 -0
  11. package/docs/superpowers/specs/2026-04-20-smarter-scanner-navigation-design.md +121 -0
  12. package/eslint.config.js +80 -0
  13. package/package.json +55 -0
  14. package/plans/DATA.md +703 -0
  15. package/plans/POLLING.md +569 -0
  16. package/plans/RUNNER.md +288 -0
  17. package/src/adapters/PuppeteerAdapter.ts +394 -0
  18. package/src/auth/credential-manager.ts +17 -0
  19. package/src/auth/form-identifier.test.ts +136 -0
  20. package/src/auth/form-identifier.ts +54 -0
  21. package/src/auth/login-executor.ts +112 -0
  22. package/src/auth/password-detector.test.ts +61 -0
  23. package/src/auth/password-detector.ts +119 -0
  24. package/src/auth/signic-registrar.ts +186 -0
  25. package/src/browser/chromium.ts +35 -0
  26. package/src/config/index.test.ts +23 -0
  27. package/src/config/index.ts +35 -0
  28. package/src/email/deep-link.test.ts +17 -0
  29. package/src/email/deep-link.ts +23 -0
  30. package/src/email/sender.ts +35 -0
  31. package/src/email/templates.ts +34 -0
  32. package/src/index.test.ts +17 -0
  33. package/src/index.ts +110 -0
  34. package/src/orchestrator.ts +220 -0
  35. package/src/plugins/content/ai-checks.ts +115 -0
  36. package/src/plugins/content/checks.test.ts +49 -0
  37. package/src/plugins/content/checks.ts +141 -0
  38. package/src/plugins/content/index.ts +73 -0
  39. package/src/plugins/registry.test.ts +49 -0
  40. package/src/plugins/registry.ts +21 -0
  41. package/src/plugins/security/header-checks.ts +56 -0
  42. package/src/plugins/security/html-checks.ts +93 -0
  43. package/src/plugins/security/index.ts +58 -0
  44. package/src/plugins/security/network-checks.test.ts +74 -0
  45. package/src/plugins/security/network-checks.ts +136 -0
  46. package/src/plugins/seo/checks.test.ts +70 -0
  47. package/src/plugins/seo/checks.ts +173 -0
  48. package/src/plugins/seo/index.ts +85 -0
  49. package/src/plugins/types.ts +43 -0
  50. package/src/plugins/ui-consistency/comparator.test.ts +108 -0
  51. package/src/plugins/ui-consistency/comparator.ts +58 -0
  52. package/src/plugins/ui-consistency/index.ts +36 -0
  53. package/src/plugins/ui-consistency/style-extractor.ts +79 -0
  54. package/src/runner/executor.test.ts +37 -0
  55. package/src/runner/executor.ts +167 -0
  56. package/src/runner/reporter.ts +19 -0
  57. package/src/runner/worker-pool.ts +106 -0
  58. package/src/runner-manager.ts +163 -0
  59. package/src/scanner/email-checker.ts +106 -0
  60. package/tsconfig.json +21 -0
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { loadConfig } from "./index";
3
+
4
+ describe("config", () => {
5
+ beforeEach(() => {
6
+ process.env.TESTOMNIAC_API_URL = "http://localhost:9999";
7
+ process.env.SCANNER_API_KEY = "test-key";
8
+ process.env.DEEP_LINK_SECRET = "test-secret";
9
+ });
10
+
11
+ it("loads required config from env", () => {
12
+ const config = loadConfig();
13
+ expect(config.apiUrl).toBe("http://localhost:9999");
14
+ expect(config.scannerApiKey).toBe("test-key");
15
+ expect(config.deepLinkSecret).toBe("test-secret");
16
+ });
17
+
18
+ it("uses defaults for optional values", () => {
19
+ const config = loadConfig();
20
+ expect(config.artifactDir).toBe("./artifacts");
21
+ expect(config.userDataDir).toBe("./browser-profile");
22
+ });
23
+ });
@@ -0,0 +1,35 @@
1
+ export interface Config {
2
+ apiUrl: string;
3
+ scannerApiKey: string;
4
+ openaiApiKey: string;
5
+ postmarkServerToken: string;
6
+ postmarkFromEmail: string;
7
+ deepLinkSecret: string;
8
+ appBaseUrl: string;
9
+ signicIndexerUrl: string;
10
+ signicWildduckUrl: string;
11
+ chromiumPath: string;
12
+ userDataDir: string;
13
+ artifactDir: string;
14
+ maxConcurrentRunners: number;
15
+ }
16
+
17
+ export function loadConfig(): Config {
18
+ return {
19
+ apiUrl: process.env.TESTOMNIAC_API_URL || "http://localhost:8027",
20
+ scannerApiKey: process.env.SCANNER_API_KEY || "",
21
+ openaiApiKey: process.env.OPENAI_API_KEY || "",
22
+ postmarkServerToken: process.env.POSTMARK_SERVER_TOKEN || "",
23
+ postmarkFromEmail: process.env.POSTMARK_FROM_EMAIL || "",
24
+ deepLinkSecret: process.env.DEEP_LINK_SECRET || "",
25
+ appBaseUrl: process.env.APP_BASE_URL || "http://localhost:3000",
26
+ signicIndexerUrl:
27
+ process.env.SIGNIC_INDEXER_URL || "https://api.signic.email/idx",
28
+ signicWildduckUrl:
29
+ process.env.SIGNIC_WILDDUCK_URL || "https://api.signic.email/api",
30
+ chromiumPath: process.env.CHROMIUM_PATH || "/usr/bin/chromium",
31
+ userDataDir: process.env.USER_DATA_DIR || "./browser-profile",
32
+ artifactDir: process.env.ARTIFACT_DIR || "./artifacts",
33
+ maxConcurrentRunners: Number(process.env.MAX_CONCURRENT_RUNNERS ?? 5),
34
+ };
35
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { generateToken, verifyToken } from "./deep-link";
3
+
4
+ describe("deep-link", () => {
5
+ const secret = "test-secret-key-32-chars-long!!!";
6
+
7
+ it("generates and verifies a token", async () => {
8
+ const token = await generateToken(42, secret);
9
+ expect(typeof token).toBe("string");
10
+ const runId = await verifyToken(token, secret);
11
+ expect(runId).toBe(42);
12
+ });
13
+
14
+ it("rejects invalid token", async () => {
15
+ await expect(verifyToken("garbage", secret)).rejects.toThrow();
16
+ });
17
+ });
@@ -0,0 +1,23 @@
1
+ import { SignJWT, jwtVerify } from "jose";
2
+
3
+ const encoder = new TextEncoder();
4
+
5
+ export async function generateToken(
6
+ runId: number,
7
+ secret: string
8
+ ): Promise<string> {
9
+ const key = encoder.encode(secret);
10
+ return new SignJWT({ runId })
11
+ .setProtectedHeader({ alg: "HS256" })
12
+ .setExpirationTime("30d")
13
+ .sign(key);
14
+ }
15
+
16
+ export async function verifyToken(
17
+ token: string,
18
+ secret: string
19
+ ): Promise<number> {
20
+ const key = encoder.encode(secret);
21
+ const { payload } = await jwtVerify(token, key);
22
+ return payload.runId as number;
23
+ }
@@ -0,0 +1,35 @@
1
+ import { ServerClient } from "postmark";
2
+ import pino from "pino";
3
+ import { loadConfig } from "../config/index";
4
+ import type { RunSummary } from "../runner/reporter";
5
+ import { buildHtmlEmail, buildTextEmail } from "./templates";
6
+ import { generateToken } from "./deep-link";
7
+
8
+ const logger = pino({ name: "email-sender" });
9
+
10
+ export async function sendReportEmail(
11
+ userEmail: string,
12
+ runnerName: string,
13
+ targetUrl: string,
14
+ runId: number,
15
+ summary: RunSummary
16
+ ): Promise<string> {
17
+ const config = loadConfig();
18
+ const token = await generateToken(runId, config.deepLinkSecret);
19
+ const deepLinkUrl = `${config.appBaseUrl}/results/${token}`;
20
+
21
+ const client = new ServerClient(config.postmarkServerToken);
22
+ const html = buildHtmlEmail(runnerName, targetUrl, summary, deepLinkUrl);
23
+ const text = buildTextEmail(runnerName, targetUrl, summary, deepLinkUrl);
24
+
25
+ await client.sendEmail({
26
+ From: config.postmarkFromEmail,
27
+ To: userEmail,
28
+ Subject: `Test Report: ${runnerName} — ${summary.passed}/${summary.total} passed`,
29
+ HtmlBody: html,
30
+ TextBody: text,
31
+ });
32
+
33
+ logger.info({ userEmail, runId }, "report email sent");
34
+ return token;
35
+ }
@@ -0,0 +1,34 @@
1
+ import type { RunSummary } from "../runner/reporter";
2
+
3
+ export function buildHtmlEmail(
4
+ runnerName: string,
5
+ targetUrl: string,
6
+ summary: RunSummary,
7
+ deepLinkUrl: string
8
+ ): string {
9
+ return `
10
+ <html><body>
11
+ <h2>Test Report: ${runnerName}</h2>
12
+ <p>Target: <a href="${targetUrl}">${targetUrl}</a></p>
13
+ <table>
14
+ <tr><td>Total</td><td>${summary.total}</td></tr>
15
+ <tr><td>Passed</td><td>${summary.passed}</td></tr>
16
+ <tr><td>Failed</td><td>${summary.failed}</td></tr>
17
+ <tr><td>Duration</td><td>${(summary.durationMs / 1000).toFixed(1)}s</td></tr>
18
+ </table>
19
+ <p><a href="${deepLinkUrl}">View Full Results</a></p>
20
+ </body></html>`.trim();
21
+ }
22
+
23
+ export function buildTextEmail(
24
+ runnerName: string,
25
+ targetUrl: string,
26
+ summary: RunSummary,
27
+ deepLinkUrl: string
28
+ ): string {
29
+ return `Test Report: ${runnerName}
30
+ Target: ${targetUrl}
31
+ Total: ${summary.total} | Passed: ${summary.passed} | Failed: ${summary.failed}
32
+ Duration: ${(summary.durationMs / 1000).toFixed(1)}s
33
+ View results: ${deepLinkUrl}`;
34
+ }
@@ -0,0 +1,17 @@
1
+ import server from "./index";
2
+
3
+ describe("scanner service", () => {
4
+ it("responds to /health", async () => {
5
+ const response = await server.fetch(new Request("http://localhost/health"));
6
+ const body = await response.json();
7
+
8
+ expect(response.status).toBe(200);
9
+ expect(body).toEqual({
10
+ ok: true,
11
+ service: "testomniac_runner",
12
+ pollIntervalMs: 10000,
13
+ maxConcurrentRunners: 5,
14
+ activeRuns: 0,
15
+ });
16
+ });
17
+ });
package/src/index.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { Hono } from "hono";
2
+ import { parseArgs } from "util";
3
+ import pino from "pino";
4
+ import { loadConfig } from "./config/index";
5
+ import { RunnerManager } from "./runner-manager";
6
+ import { runFullScan, runSequenceScan } from "./orchestrator";
7
+
8
+ const logger = pino({
9
+ name: "testomniac_runner",
10
+ level: process.env.LOG_LEVEL ?? "info",
11
+ });
12
+
13
+ const port = Number(process.env.PORT ?? 8030);
14
+ const config = loadConfig();
15
+ const pollIntervalMs = Number(process.env.SCAN_POLL_INTERVAL_MS ?? 10_000);
16
+ const maxConcurrentRunners = config.maxConcurrentRunners;
17
+ const runnerManager = new RunnerManager(pollIntervalMs, maxConcurrentRunners);
18
+
19
+ const app = new Hono();
20
+
21
+ app.get("/", c => {
22
+ return c.json({
23
+ service: "testomniac_runner",
24
+ status: "ok",
25
+ });
26
+ });
27
+
28
+ app.get("/health", c => {
29
+ return c.json({
30
+ ok: true,
31
+ service: "testomniac_runner",
32
+ pollIntervalMs,
33
+ maxConcurrentRunners,
34
+ activeRuns: runnerManager.getActiveRunCount(),
35
+ });
36
+ });
37
+
38
+ if (import.meta.main) {
39
+ const { values: args } = parseArgs({
40
+ args: process.argv.slice(2),
41
+ options: {
42
+ "run-id": { type: "string" },
43
+ "sequence-run-id": { type: "string" },
44
+ "runner-id": { type: "string" },
45
+ "base-url": { type: "string" },
46
+ "size-class": { type: "string", default: "desktop" },
47
+ },
48
+ strict: false,
49
+ });
50
+
51
+ if (args["run-id"]) {
52
+ // One-shot mode: execute a specific test run and exit
53
+ const scanId = Number(args["run-id"]);
54
+ const runnerId = Number(args["runner-id"]);
55
+ const baseUrl = String(args["base-url"] ?? "");
56
+ const sizeClass = String(args["size-class"] ?? "desktop");
57
+
58
+ logger.info({ scanId, runnerId }, "one-shot mode: executing test run");
59
+ try {
60
+ await runFullScan({
61
+ runnerId,
62
+ scanId,
63
+ scanUrl: baseUrl,
64
+ baseUrl,
65
+ sizeClass,
66
+ runnerInstanceId: crypto.randomUUID(),
67
+ runnerInstanceName: "mcp-runner",
68
+ });
69
+ logger.info({ scanId }, "one-shot run completed");
70
+ process.exit(0);
71
+ } catch (err) {
72
+ logger.error({ err, scanId }, "one-shot run failed");
73
+ process.exit(1);
74
+ }
75
+ } else if (args["sequence-run-id"]) {
76
+ // One-shot mode: execute a specific sequence run and exit
77
+ const sequenceRunId = Number(args["sequence-run-id"]);
78
+ const runnerId = Number(args["runner-id"]);
79
+
80
+ logger.info(
81
+ { sequenceRunId, runnerId },
82
+ "one-shot mode: executing sequence run"
83
+ );
84
+ try {
85
+ await runSequenceScan({ sequenceRunId, runnerId });
86
+ logger.info({ sequenceRunId }, "one-shot sequence run completed");
87
+ process.exit(0);
88
+ } catch (err) {
89
+ logger.error({ err, sequenceRunId }, "one-shot sequence run failed");
90
+ process.exit(1);
91
+ }
92
+ } else {
93
+ // Default: polling mode
94
+ setInterval(() => {
95
+ void runnerManager.tick();
96
+ }, pollIntervalMs);
97
+
98
+ void runnerManager.tick();
99
+
100
+ logger.warn(
101
+ { port, pollIntervalMs, maxConcurrentRunners },
102
+ "starting scanner service"
103
+ );
104
+ }
105
+ }
106
+
107
+ export default {
108
+ port,
109
+ fetch: app.fetch,
110
+ };
@@ -0,0 +1,220 @@
1
+ import pino from "pino";
2
+ import { ChromiumManager } from "./browser/chromium";
3
+ import { loadConfig } from "./config/index";
4
+ import { computeSummary } from "./runner/reporter";
5
+ import { sendReportEmail } from "./email/sender";
6
+ import { PuppeteerAdapter } from "./adapters/PuppeteerAdapter";
7
+ import {
8
+ getApiClient,
9
+ runTestRun,
10
+ runSequenceRun,
11
+ createDefaultExpertises,
12
+ type ScanEventHandler,
13
+ SizeClass,
14
+ DESKTOP_SCREENS,
15
+ MOBILE_SCREENS,
16
+ } from "@sudobility/testomniac_runner_service";
17
+ import type {
18
+ Credentials,
19
+ Screen,
20
+ } from "@sudobility/testomniac_runner_service";
21
+
22
+ const logger = pino({ name: "orchestrator" });
23
+
24
+ function elapsed(start: number): number {
25
+ return Date.now() - start;
26
+ }
27
+
28
+ export interface RunOptions {
29
+ runnerId: number;
30
+ scanId: number;
31
+ scanUrl: string;
32
+ baseUrl: string;
33
+ sizeClass: string;
34
+ runnerName?: string;
35
+ runnerInstanceId?: string;
36
+ runnerInstanceName?: string;
37
+ credentials?: Credentials;
38
+ userEmail?: string;
39
+ scanScopePath?: string;
40
+ loginUrl?: string;
41
+ entityCredentialId?: number;
42
+ quickScan?: boolean;
43
+ }
44
+
45
+ export async function runFullScan(options: RunOptions): Promise<void> {
46
+ const config = loadConfig();
47
+ const api = getApiClient(
48
+ config.apiUrl + "/api/v1/scanner",
49
+ config.scannerApiKey
50
+ );
51
+ const { scanUrl, baseUrl, userEmail } = options;
52
+ const runnerName = options.runnerName || new URL(scanUrl).hostname;
53
+ const sizeClass = (options.sizeClass as SizeClass) || SizeClass.Desktop;
54
+
55
+ const runStart = Date.now();
56
+
57
+ const chromium = new ChromiumManager(config);
58
+ await chromium.launch();
59
+ const runnerId = options.runnerId;
60
+ const scanId = options.scanId;
61
+
62
+ try {
63
+ const defaultScreen: Screen =
64
+ sizeClass === SizeClass.Desktop ? DESKTOP_SCREENS[0] : MOBILE_SCREENS[0];
65
+ const page = await chromium.newPage(defaultScreen);
66
+ const adapter = new PuppeteerAdapter(page);
67
+
68
+ const eventHandler: ScanEventHandler = {
69
+ onPageFound: p =>
70
+ logger.info(
71
+ { relativePath: p.relativePath, pageId: p.pageId },
72
+ "page discovered"
73
+ ),
74
+ onPageStateCreated: s =>
75
+ logger.info({ pageStateId: s.pageStateId }, "page state created"),
76
+ onTestSurfaceCreated: s =>
77
+ logger.info(
78
+ { surfaceId: s.surfaceId, title: s.title },
79
+ "test surface created"
80
+ ),
81
+ onTestInteractionRunCompleted: r =>
82
+ logger.info(
83
+ { testInteractionRunId: r.testInteractionRunId, passed: r.passed },
84
+ "test element run completed"
85
+ ),
86
+ onTestRunCompleted: r =>
87
+ logger.info(
88
+ { testRunId: r.testRunId, passed: r.passed },
89
+ "test run completed"
90
+ ),
91
+ onFindingCreated: f =>
92
+ logger.warn({ type: f.type, title: f.title }, "finding created"),
93
+ onStatsUpdated: stats => logger.debug(stats, "stats updated"),
94
+ onScreenshotCaptured: () => {},
95
+ onScanComplete: summary => logger.info(summary, "scan complete"),
96
+ onError: err => logger.error(err, "scan error"),
97
+ };
98
+
99
+ const runnerInstanceId = options.runnerInstanceId ?? crypto.randomUUID();
100
+ const runnerInstanceName = options.runnerInstanceName ?? runnerName;
101
+ const expertises = createDefaultExpertises();
102
+
103
+ await runTestRun(
104
+ adapter,
105
+ {
106
+ testRunId: scanId,
107
+ runnerId,
108
+ testEnvironmentId: undefined,
109
+ baseUrl,
110
+ sizeClass,
111
+ runnerInstanceId,
112
+ runnerInstanceName,
113
+ scanScopePath: options.scanScopePath,
114
+ loginUrl: options.loginUrl,
115
+ entityCredentialId: options.entityCredentialId,
116
+ credentials: options.credentials,
117
+ quickScan: options.quickScan,
118
+ },
119
+ api,
120
+ expertises,
121
+ eventHandler
122
+ );
123
+
124
+ // Send email report after scan completes
125
+ if (userEmail) {
126
+ const summary = computeSummary([]);
127
+ const token = await sendReportEmail(
128
+ userEmail,
129
+ runnerName,
130
+ scanUrl,
131
+ scanId,
132
+ summary
133
+ );
134
+ await api.createReportEmail({
135
+ rootTestRunId: scanId,
136
+ userEmail,
137
+ deepLinkToken: token,
138
+ });
139
+ }
140
+
141
+ await page.close();
142
+
143
+ logger.info(
144
+ { scanId, sizeClass, totalDurationMs: elapsed(runStart) },
145
+ "run complete"
146
+ );
147
+ } finally {
148
+ await chromium.close();
149
+ }
150
+ }
151
+
152
+ export interface SequenceRunOptions {
153
+ sequenceRunId: number;
154
+ runnerId: number;
155
+ sizeClass?: string;
156
+ }
157
+
158
+ export async function runSequenceScan(
159
+ options: SequenceRunOptions
160
+ ): Promise<void> {
161
+ const config = loadConfig();
162
+ const api = getApiClient(
163
+ config.apiUrl + "/api/v1/scanner",
164
+ config.scannerApiKey
165
+ );
166
+
167
+ const runStart = Date.now();
168
+ const sizeClass = (options.sizeClass as SizeClass) || SizeClass.Desktop;
169
+
170
+ const chromium = new ChromiumManager(config);
171
+ await chromium.launch();
172
+
173
+ try {
174
+ const defaultScreen =
175
+ sizeClass === SizeClass.Desktop ? DESKTOP_SCREENS[0] : MOBILE_SCREENS[0];
176
+ const page = await chromium.newPage(defaultScreen);
177
+ const adapter = new PuppeteerAdapter(page);
178
+ const expertises = createDefaultExpertises();
179
+
180
+ const eventHandler: ScanEventHandler = {
181
+ onPageFound: p => logger.info(p, "page found"),
182
+ onPageStateCreated: s => logger.info(s, "page state created"),
183
+ onTestSurfaceCreated: s => logger.info(s, "surface created"),
184
+ onTestInteractionRunCompleted: r =>
185
+ logger.info(r, "interaction completed"),
186
+ onTestRunCompleted: r => logger.info(r, "run completed"),
187
+ onFindingCreated: f => logger.warn(f, "finding created"),
188
+ onStatsUpdated: s => logger.debug(s, "stats"),
189
+ onScreenshotCaptured: () => {},
190
+ onScanComplete: s => logger.info(s, "complete"),
191
+ onError: e => logger.error(e, "error"),
192
+ };
193
+
194
+ await runSequenceRun(
195
+ adapter,
196
+ {
197
+ sequenceRunId: options.sequenceRunId,
198
+ runnerId: options.runnerId,
199
+ sizeClass,
200
+ runnerInstanceId: crypto.randomUUID(),
201
+ runnerInstanceName: "mcp-runner",
202
+ },
203
+ api,
204
+ expertises,
205
+ eventHandler
206
+ );
207
+
208
+ await page.close();
209
+
210
+ logger.info(
211
+ {
212
+ sequenceRunId: options.sequenceRunId,
213
+ totalDurationMs: elapsed(runStart),
214
+ },
215
+ "sequence run complete"
216
+ );
217
+ } finally {
218
+ await chromium.close();
219
+ }
220
+ }
@@ -0,0 +1,115 @@
1
+ import pino from "pino";
2
+ import type { OpenAI } from "openai";
3
+ import type { PluginIssue } from "../types";
4
+
5
+ const logger = pino({ name: "ai-checks" });
6
+
7
+ export async function checkSpellingAndGrammar(
8
+ client: OpenAI,
9
+ text: string,
10
+ pageUrl: string
11
+ ): Promise<PluginIssue[]> {
12
+ if (text.trim().length < 20) {
13
+ return [];
14
+ }
15
+
16
+ const truncated = text.substring(0, 3000);
17
+
18
+ const response = await client.chat.completions.create({
19
+ model: "gpt-4o-mini",
20
+ messages: [
21
+ {
22
+ role: "system",
23
+ content:
24
+ "You are a proofreader. Find spelling and grammar errors in the text. Return a JSON array of objects with 'error' (the mistake) and 'suggestion' (the correction). Return an empty array if no errors are found. Only output valid JSON.",
25
+ },
26
+ {
27
+ role: "user",
28
+ content: truncated,
29
+ },
30
+ ],
31
+ temperature: 0,
32
+ response_format: { type: "json_object" },
33
+ });
34
+
35
+ const content = response.choices[0]?.message?.content;
36
+ if (!content) return [];
37
+
38
+ try {
39
+ const parsed = JSON.parse(content);
40
+ const errors: { error: string; suggestion: string }[] =
41
+ parsed.errors ?? parsed;
42
+
43
+ if (!Array.isArray(errors)) return [];
44
+
45
+ return errors.map(err => ({
46
+ type: "content-spelling-grammar",
47
+ severity: "info" as const,
48
+ description: `Spelling/grammar: "${err.error}" -> "${err.suggestion}"`,
49
+ pageUrl,
50
+ details: { error: err.error, suggestion: err.suggestion },
51
+ }));
52
+ } catch (err) {
53
+ logger.warn({ err }, "failed to parse LLM spelling/grammar response");
54
+ return [];
55
+ }
56
+ }
57
+
58
+ export async function checkTerminologyConsistency(
59
+ client: OpenAI,
60
+ pageTexts: { url: string; text: string }[]
61
+ ): Promise<PluginIssue[]> {
62
+ if (pageTexts.length < 2) {
63
+ return [];
64
+ }
65
+
66
+ const summaries = pageTexts
67
+ .map((p, i) => `Page ${i + 1} (${p.url}):\n${p.text.substring(0, 1000)}`)
68
+ .join("\n\n---\n\n");
69
+
70
+ const response = await client.chat.completions.create({
71
+ model: "gpt-4o-mini",
72
+ messages: [
73
+ {
74
+ role: "system",
75
+ content:
76
+ "You check for inconsistent terminology across web pages. Find cases where different pages use different terms for the same concept (e.g., 'sign in' vs 'log in'). Return a JSON object with an 'inconsistencies' array of objects with 'term1', 'term2', 'pages' (array of page numbers). Return empty array if consistent.",
77
+ },
78
+ {
79
+ role: "user",
80
+ content: summaries,
81
+ },
82
+ ],
83
+ temperature: 0,
84
+ response_format: { type: "json_object" },
85
+ });
86
+
87
+ const content = response.choices[0]?.message?.content;
88
+ if (!content) return [];
89
+
90
+ try {
91
+ const parsed = JSON.parse(content);
92
+ const inconsistencies: {
93
+ term1: string;
94
+ term2: string;
95
+ pages: number[];
96
+ }[] = parsed.inconsistencies ?? [];
97
+
98
+ if (!Array.isArray(inconsistencies)) return [];
99
+
100
+ return inconsistencies.map(item => ({
101
+ type: "content-terminology-inconsistency",
102
+ severity: "info" as const,
103
+ description: `Inconsistent terminology: "${item.term1}" vs "${item.term2}"`,
104
+ pageUrl: pageTexts[0].url,
105
+ details: {
106
+ term1: item.term1,
107
+ term2: item.term2,
108
+ pages: item.pages.map(i => pageTexts[i - 1]?.url ?? `Page ${i}`),
109
+ },
110
+ }));
111
+ } catch (err) {
112
+ logger.warn({ err }, "failed to parse LLM terminology response");
113
+ return [];
114
+ }
115
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ checkPlaceholderContent,
4
+ checkReadability,
5
+ checkCopyrightYear,
6
+ } from "./checks";
7
+
8
+ const PAGE_URL = "https://example.com";
9
+
10
+ describe("content checks", () => {
11
+ it("flags lorem ipsum placeholder text", () => {
12
+ const text = "Welcome to our site. Lorem ipsum dolor sit amet.";
13
+ const issues = checkPlaceholderContent(text, PAGE_URL);
14
+ expect(issues.length).toBeGreaterThanOrEqual(1);
15
+ expect(issues[0].type).toBe("content-placeholder");
16
+ expect(issues[0].details?.match).toBe("Lorem ipsum");
17
+ });
18
+
19
+ it("flags TODO placeholder text", () => {
20
+ const text = "This section is TODO and needs updating.";
21
+ const issues = checkPlaceholderContent(text, PAGE_URL);
22
+ expect(issues.length).toBeGreaterThanOrEqual(1);
23
+ expect(issues.some(i => i.details?.match === "TODO")).toBe(true);
24
+ });
25
+
26
+ it("calculates readability score for long text", () => {
27
+ // Generate text with 30+ words that is simple enough not to trigger high grade level
28
+ const simpleText =
29
+ "The cat sat on the mat. The dog ran in the park. The bird sang in the tree. The fish swam in the sea. The sun was warm and bright today. The children played outside all day long.";
30
+ const issue = checkReadability(simpleText, PAGE_URL);
31
+ // Simple text should not flag as too hard to read
32
+ expect(issue).toBeNull();
33
+ });
34
+
35
+ it("flags outdated copyright year", () => {
36
+ const text = "© 2020 Example Corp. All rights reserved.";
37
+ const issue = checkCopyrightYear(text, PAGE_URL);
38
+ expect(issue).not.toBeNull();
39
+ expect(issue!.type).toBe("content-outdated-copyright");
40
+ expect(issue!.details?.year).toBe(2020);
41
+ });
42
+
43
+ it("passes current copyright year", () => {
44
+ const currentYear = new Date().getFullYear();
45
+ const text = `© ${currentYear} Example Corp. All rights reserved.`;
46
+ const issue = checkCopyrightYear(text, PAGE_URL);
47
+ expect(issue).toBeNull();
48
+ });
49
+ });