@_deep4wee/agent-lens 1.0.1 → 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 +141 -31
- package/dist/cli.d.mts +2 -1
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +1169 -957
- package/dist/cli.js.map +1 -1
- package/dist/cli.mjs +1268 -974
- package/dist/cli.mjs.map +1 -1
- package/dist/dsl-BIjVN1M0.d.mts +204 -0
- package/dist/dsl-BIjVN1M0.d.ts +204 -0
- package/dist/index.d.mts +131 -155
- package/dist/index.d.ts +131 -155
- package/dist/index.js +1506 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1479 -3
- package/dist/index.mjs.map +1 -1
- package/dist/plugins/a11y-tree/index.d.mts +11 -0
- package/dist/plugins/a11y-tree/index.d.ts +11 -0
- package/dist/plugins/a11y-tree/index.js +190 -0
- package/dist/plugins/a11y-tree/index.js.map +1 -0
- package/dist/plugins/a11y-tree/index.mjs +155 -0
- package/dist/plugins/a11y-tree/index.mjs.map +1 -0
- package/dist/plugins/desktop-webview2/index.d.mts +14 -0
- package/dist/plugins/desktop-webview2/index.d.ts +14 -0
- package/dist/plugins/desktop-webview2/index.js +258 -0
- package/dist/plugins/desktop-webview2/index.js.map +1 -0
- package/dist/plugins/desktop-webview2/index.mjs +221 -0
- package/dist/plugins/desktop-webview2/index.mjs.map +1 -0
- package/dist/plugins/live-controller/index.d.mts +35 -0
- package/dist/plugins/live-controller/index.d.ts +35 -0
- package/dist/plugins/live-controller/index.js +303 -0
- package/dist/plugins/live-controller/index.js.map +1 -0
- package/dist/plugins/live-controller/index.mjs +261 -0
- package/dist/plugins/live-controller/index.mjs.map +1 -0
- package/dist/plugins/mock-ipc/index.d.mts +30 -0
- package/dist/plugins/mock-ipc/index.d.ts +30 -0
- package/dist/plugins/mock-ipc/index.js +210 -0
- package/dist/plugins/mock-ipc/index.js.map +1 -0
- package/dist/plugins/mock-ipc/index.mjs +181 -0
- package/dist/plugins/mock-ipc/index.mjs.map +1 -0
- package/dist/plugins/visual-diff/index.d.mts +30 -0
- package/dist/plugins/visual-diff/index.d.ts +30 -0
- package/dist/plugins/visual-diff/index.js +163 -0
- package/dist/plugins/visual-diff/index.js.map +1 -0
- package/dist/plugins/visual-diff/index.mjs +127 -0
- package/dist/plugins/visual-diff/index.mjs.map +1 -0
- package/docs/plugins.md +415 -0
- package/package.json +40 -2
- package/skills/agent-lens/SKILL.md +142 -37
- package/skills/agent-lens/examples/06-state-testing-with-mock-ipc.md +13 -12
- package/skills/agent-lens/examples/07-live-controller-interactive-loop.md +108 -0
- package/skills/agent-lens/examples/08-accessibility-semantic-inspection.md +80 -0
- package/skills/agent-lens/examples/09-visual-regression-and-pixel-diffing.md +66 -0
- package/skills/agent-lens/examples/10-authoring-custom-agent-plugins.md +85 -0
- package/skills/agent-lens/references/plugin-development.md +165 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// src/shared/api/plugin.ts
|
|
2
|
+
function definePlugin(plugin) {
|
|
3
|
+
return plugin;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// src/plugins/mock-ipc/registry.ts
|
|
7
|
+
var MockIpcRegistry = class {
|
|
8
|
+
mocks = /* @__PURE__ */ new Map();
|
|
9
|
+
set(action, data, options) {
|
|
10
|
+
this.mocks.set(action, {
|
|
11
|
+
action,
|
|
12
|
+
data,
|
|
13
|
+
type: options?.type ?? "SUCCESS",
|
|
14
|
+
delayMs: options?.delayMs ?? 20
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
setBatch(entries) {
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
this.set(entry.action, entry.data, { type: entry.type, delayMs: entry.delayMs });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
remove(action) {
|
|
23
|
+
this.mocks.delete(action);
|
|
24
|
+
}
|
|
25
|
+
clear() {
|
|
26
|
+
this.mocks.clear();
|
|
27
|
+
}
|
|
28
|
+
get(action) {
|
|
29
|
+
return this.mocks.get(action) ?? null;
|
|
30
|
+
}
|
|
31
|
+
toSerializable() {
|
|
32
|
+
const result = {};
|
|
33
|
+
for (const [action, entry] of this.mocks.entries()) {
|
|
34
|
+
result[action] = {
|
|
35
|
+
data: entry.data,
|
|
36
|
+
type: entry.type ?? "SUCCESS",
|
|
37
|
+
delayMs: entry.delayMs ?? 20
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
get size() {
|
|
43
|
+
return this.mocks.size;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
function generateMockIpcScript(registry) {
|
|
47
|
+
const mocksJson = JSON.stringify(registry.toSerializable());
|
|
48
|
+
return `
|
|
49
|
+
(() => {
|
|
50
|
+
const __mockTable = ${mocksJson};
|
|
51
|
+
|
|
52
|
+
window.__visualRunnerMocks = __mockTable;
|
|
53
|
+
|
|
54
|
+
// Generic IPC mock bridge for modern web applications
|
|
55
|
+
window.__mockIpc = {
|
|
56
|
+
invoke: (action, payload) => {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const mock = window.__visualRunnerMocks[action];
|
|
59
|
+
|
|
60
|
+
if (mock) {
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
if (mock.type === 'ERROR') {
|
|
63
|
+
reject(new Error(mock.data));
|
|
64
|
+
} else {
|
|
65
|
+
resolve(mock.data);
|
|
66
|
+
}
|
|
67
|
+
}, mock.delayMs || 20);
|
|
68
|
+
} else {
|
|
69
|
+
console.warn('[Mock IPC] No mock for action:', action, '\u2014 returning empty SUCCESS');
|
|
70
|
+
setTimeout(() => resolve(null), 20);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Safe fallback bridge for hybrid webviews (Photino / CEF / WebView2)
|
|
77
|
+
try {
|
|
78
|
+
if (!window.external) {
|
|
79
|
+
window.external = {};
|
|
80
|
+
}
|
|
81
|
+
window.external.sendMessage = (msg) => {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
|
|
84
|
+
const action = parsed.Action || parsed.action;
|
|
85
|
+
const id = parsed.Id || parsed.id;
|
|
86
|
+
const mock = window.__visualRunnerMocks[action];
|
|
87
|
+
|
|
88
|
+
if (mock) {
|
|
89
|
+
const response = { Id: id, Type: mock.type || 'SUCCESS', Data: mock.data };
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
const cb = window.__mockCallback;
|
|
92
|
+
if (typeof cb === 'function') cb(JSON.stringify(response));
|
|
93
|
+
}, mock.delayMs || 20);
|
|
94
|
+
} else {
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
const cb = window.__mockCallback;
|
|
97
|
+
if (typeof cb === 'function') cb(JSON.stringify({ Id: id, Type: 'SUCCESS', Data: null }));
|
|
98
|
+
}, 20);
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
console.error('[Mock IPC] Failed to process message:', e);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
window.external.receiveMessage = (callback) => {
|
|
106
|
+
window.__mockCallback = callback;
|
|
107
|
+
};
|
|
108
|
+
} catch {
|
|
109
|
+
// Ignored if window.external is read-only in strict Chromium sandboxes
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log('[Visual Runner] Mock IPC bridge initialized with', Object.keys(window.__visualRunnerMocks).length, 'mocked actions');
|
|
113
|
+
})();
|
|
114
|
+
`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/plugins/mock-ipc/index.ts
|
|
118
|
+
var STATE_KEY = "mockIpcRegistry";
|
|
119
|
+
var mockIpcPlugin = definePlugin({
|
|
120
|
+
name: "mock-ipc",
|
|
121
|
+
version: "1.0.0",
|
|
122
|
+
setup: (hookContext) => {
|
|
123
|
+
const registry = new MockIpcRegistry();
|
|
124
|
+
hookContext.state.set(STATE_KEY, registry);
|
|
125
|
+
const scenarioMocks = hookContext.scenario?.mockIpc || [];
|
|
126
|
+
const globalMocks = hookContext.cliOptions?.globalMocks || [];
|
|
127
|
+
const allMocks = [...globalMocks, ...scenarioMocks];
|
|
128
|
+
if (allMocks.length > 0) {
|
|
129
|
+
console.log(`\u{1F4E6} [Plugin:mock-ipc] Pre-loading ${allMocks.length} initial IPC mocks`);
|
|
130
|
+
registry.setBatch(allMocks);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
onContextCreated: async (context, hookContext) => {
|
|
134
|
+
const registry = hookContext.state.get(STATE_KEY) || new MockIpcRegistry();
|
|
135
|
+
const script = generateMockIpcScript(registry);
|
|
136
|
+
await context.addInitScript(script);
|
|
137
|
+
console.log(`\u{1F50C} [Plugin:mock-ipc] Injected window.__mockIpc & window.external bridge into browser context`);
|
|
138
|
+
},
|
|
139
|
+
extendContext: (_ctx, page, hookContext) => {
|
|
140
|
+
const registry = hookContext.state.get(STATE_KEY) || new MockIpcRegistry();
|
|
141
|
+
return {
|
|
142
|
+
setMockIpc: async (action, data, options) => {
|
|
143
|
+
const preview = typeof data === "string" ? data : JSON.stringify(data).slice(0, 80);
|
|
144
|
+
console.log(`\u{1F4E6} [Plugin:mock-ipc] Dynamic set: ${action} -> ${preview}`);
|
|
145
|
+
registry.set(action, data, options);
|
|
146
|
+
await page.evaluate(
|
|
147
|
+
({ action: action2, mock }) => {
|
|
148
|
+
const w = window;
|
|
149
|
+
if (!w.__visualRunnerMocks) {
|
|
150
|
+
w.__visualRunnerMocks = {};
|
|
151
|
+
}
|
|
152
|
+
w.__visualRunnerMocks[action2] = mock;
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
action,
|
|
156
|
+
mock: {
|
|
157
|
+
data,
|
|
158
|
+
type: options?.type ?? "SUCCESS",
|
|
159
|
+
delayMs: options?.delayMs ?? 20
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
teardown: (hookContext) => {
|
|
167
|
+
const registry = hookContext.state.get(STATE_KEY);
|
|
168
|
+
if (registry) {
|
|
169
|
+
registry.clear();
|
|
170
|
+
hookContext.state.delete(STATE_KEY);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
var mock_ipc_default = mockIpcPlugin;
|
|
175
|
+
export {
|
|
176
|
+
MockIpcRegistry,
|
|
177
|
+
mock_ipc_default as default,
|
|
178
|
+
generateMockIpcScript,
|
|
179
|
+
mockIpcPlugin
|
|
180
|
+
};
|
|
181
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/shared/api/plugin.ts","../../../src/plugins/mock-ipc/registry.ts","../../../src/plugins/mock-ipc/index.ts"],"sourcesContent":["import type { Page, BrowserContext, Browser } from 'playwright';\nimport type { TestContext, VisualScenario } from './dsl';\nimport type { ReportData } from '../types/report';\n\nexport interface DriverLaunchResult {\n page: Page;\n context: BrowserContext;\n browser?: Browser;\n stop?: () => Promise<void>;\n}\n\nexport interface PluginHookContext {\n scenario?: VisualScenario;\n targetMode: 'desktop' | 'preview' | string;\n artifactsDir: string;\n cliOptions?: Record<string, unknown>;\n state: Map<string, unknown>;\n}\n\nexport interface AgentLensPlugin {\n /** Unique plugin identifier (e.g. 'desktop-webview2', 'live-controller', 'mock-ipc') */\n name: string;\n version?: string;\n\n /** Lifecycle hook: initial setup before drivers and scenarios start */\n setup?: (context: PluginHookContext) => Promise<void> | void;\n\n /**\n * Microkernel driver hook: allows a plugin to provide a custom browser/page session\n * (e.g., desktop WebView2 CDP connection, custom remote browser).\n */\n launchSession?: (\n options: { currentViewport: { width: number; height: number }; headed?: boolean },\n hookContext: PluginHookContext\n ) => Promise<DriverLaunchResult | undefined>;\n\n /** Lifecycle hook: called when browser context is ready */\n onContextCreated?: (context: BrowserContext, hookContext: PluginHookContext) => Promise<void> | void;\n\n /** Lifecycle hook: called when the test page is created/navigated */\n onPageCreated?: (page: Page, context: BrowserContext, hookContext: PluginHookContext) => Promise<void> | void;\n\n /**\n * Context extension: inject custom methods or properties directly into TestContext (ctx)\n */\n extendContext?: (\n ctx: TestContext,\n page: Page,\n hookContext: PluginHookContext\n ) => Record<string, any> | Promise<Record<string, any>>;\n\n /** Lifecycle hook: called after scenario runs and report data is calculated */\n onAfterRun?: (reportData: ReportData, hookContext: PluginHookContext) => Promise<void> | void;\n\n /** Lifecycle hook: teardown and cleanup guaranteed to run */\n teardown?: (hookContext: PluginHookContext) => Promise<void> | void;\n}\n\nexport function definePlugin(plugin: AgentLensPlugin): AgentLensPlugin {\n return plugin;\n}","import type { MockIpcResponseType, MockIpcEntry } from '../../shared/types/ipc';\n\nexport class MockIpcRegistry {\n private mocks = new Map<string, MockIpcEntry>();\n\n public set(action: string, data: any, options?: { type?: MockIpcResponseType; delayMs?: number }): void {\n this.mocks.set(action, {\n action,\n data,\n type: options?.type ?? 'SUCCESS',\n delayMs: options?.delayMs ?? 20\n });\n }\n\n public setBatch(entries: Array<{ action: string; data: any; type?: MockIpcResponseType; delayMs?: number }>): void {\n for (const entry of entries) {\n this.set(entry.action, entry.data, { type: entry.type, delayMs: entry.delayMs });\n }\n }\n\n public remove(action: string): void {\n this.mocks.delete(action);\n }\n\n public clear(): void {\n this.mocks.clear();\n }\n\n public get(action: string): MockIpcEntry | null {\n return this.mocks.get(action) ?? null;\n }\n\n public toSerializable(): Record<string, { data: any; type: string; delayMs: number }> {\n const result: Record<string, { data: any; type: string; delayMs: number }> = {};\n for (const [action, entry] of this.mocks.entries()) {\n result[action] = {\n data: entry.data,\n type: entry.type ?? 'SUCCESS',\n delayMs: entry.delayMs ?? 20\n };\n }\n return result;\n }\n\n public get size(): number {\n return this.mocks.size;\n }\n}\n\nexport function generateMockIpcScript(registry: MockIpcRegistry): string {\n const mocksJson = JSON.stringify(registry.toSerializable());\n\n return `\n (() => {\n const __mockTable = ${mocksJson};\n\n window.__visualRunnerMocks = __mockTable;\n\n // Generic IPC mock bridge for modern web applications\n window.__mockIpc = {\n invoke: (action, payload) => {\n return new Promise((resolve, reject) => {\n const mock = window.__visualRunnerMocks[action];\n\n if (mock) {\n setTimeout(() => {\n if (mock.type === 'ERROR') {\n reject(new Error(mock.data));\n } else {\n resolve(mock.data);\n }\n }, mock.delayMs || 20);\n } else {\n console.warn('[Mock IPC] No mock for action:', action, '— returning empty SUCCESS');\n setTimeout(() => resolve(null), 20);\n }\n });\n }\n };\n\n // Safe fallback bridge for hybrid webviews (Photino / CEF / WebView2)\n try {\n if (!window.external) {\n window.external = {};\n }\n window.external.sendMessage = (msg) => {\n try {\n const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;\n const action = parsed.Action || parsed.action;\n const id = parsed.Id || parsed.id;\n const mock = window.__visualRunnerMocks[action];\n\n if (mock) {\n const response = { Id: id, Type: mock.type || 'SUCCESS', Data: mock.data };\n setTimeout(() => {\n const cb = window.__mockCallback;\n if (typeof cb === 'function') cb(JSON.stringify(response));\n }, mock.delayMs || 20);\n } else {\n setTimeout(() => {\n const cb = window.__mockCallback;\n if (typeof cb === 'function') cb(JSON.stringify({ Id: id, Type: 'SUCCESS', Data: null }));\n }, 20);\n }\n } catch (e) {\n console.error('[Mock IPC] Failed to process message:', e);\n }\n };\n\n window.external.receiveMessage = (callback) => {\n window.__mockCallback = callback;\n };\n } catch {\n // Ignored if window.external is read-only in strict Chromium sandboxes\n }\n\n console.log('[Visual Runner] Mock IPC bridge initialized with', Object.keys(window.__visualRunnerMocks).length, 'mocked actions');\n })();\n `;\n}\n","import { definePlugin, type AgentLensPlugin } from '../../shared/api/plugin';\nimport { MockIpcRegistry, generateMockIpcScript } from './registry';\nimport type { MockIpcResponseType } from '../../shared/types/ipc';\n\nexport { MockIpcRegistry, generateMockIpcScript };\n\nconst STATE_KEY = 'mockIpcRegistry';\n\nexport const mockIpcPlugin: AgentLensPlugin = definePlugin({\n name: 'mock-ipc',\n version: '1.0.0',\n\n setup: (hookContext) => {\n const registry = new MockIpcRegistry();\n hookContext.state.set(STATE_KEY, registry);\n\n const scenarioMocks = hookContext.scenario?.mockIpc || [];\n const globalMocks = (hookContext.cliOptions?.globalMocks as Array<{ action: string; data: unknown; type?: MockIpcResponseType; delayMs?: number }>) || [];\n const allMocks = [...globalMocks, ...scenarioMocks];\n\n if (allMocks.length > 0) {\n console.log(`📦 [Plugin:mock-ipc] Pre-loading ${allMocks.length} initial IPC mocks`);\n registry.setBatch(allMocks);\n }\n },\n\n onContextCreated: async (context, hookContext) => {\n const registry = (hookContext.state.get(STATE_KEY) as MockIpcRegistry) || new MockIpcRegistry();\n const script = generateMockIpcScript(registry);\n await context.addInitScript(script);\n console.log(`🔌 [Plugin:mock-ipc] Injected window.__mockIpc & window.external bridge into browser context`);\n },\n\n extendContext: (_ctx, page, hookContext) => {\n const registry = (hookContext.state.get(STATE_KEY) as MockIpcRegistry) || new MockIpcRegistry();\n\n return {\n setMockIpc: async (\n action: string,\n data: unknown,\n options?: { type?: MockIpcResponseType; delayMs?: number }\n ) => {\n const preview = typeof data === 'string' ? data : JSON.stringify(data).slice(0, 80);\n console.log(`📦 [Plugin:mock-ipc] Dynamic set: ${action} -> ${preview}`);\n registry.set(action, data, options);\n\n await page.evaluate(\n ({ action, mock }) => {\n const w = window as unknown as { __visualRunnerMocks?: Record<string, unknown> };\n if (!w.__visualRunnerMocks) {\n w.__visualRunnerMocks = {};\n }\n w.__visualRunnerMocks[action] = mock;\n },\n {\n action,\n mock: {\n data,\n type: options?.type ?? 'SUCCESS',\n delayMs: options?.delayMs ?? 20\n }\n }\n );\n }\n };\n },\n\n teardown: (hookContext) => {\n const registry = hookContext.state.get(STATE_KEY) as MockIpcRegistry | undefined;\n if (registry) {\n registry.clear();\n hookContext.state.delete(STATE_KEY);\n }\n }\n});\n\nexport default mockIpcPlugin;\n"],"mappings":"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","names":["action"]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { A as AgentLensPlugin } from '../../dsl-BIjVN1M0.mjs';
|
|
2
|
+
import 'playwright';
|
|
3
|
+
|
|
4
|
+
interface VisualDiffOptions {
|
|
5
|
+
threshold?: number;
|
|
6
|
+
includeAA?: boolean;
|
|
7
|
+
diffColor?: [number, number, number];
|
|
8
|
+
}
|
|
9
|
+
interface VisualDiffResult {
|
|
10
|
+
diffPixels: number;
|
|
11
|
+
totalPixels: number;
|
|
12
|
+
diffPercent: number;
|
|
13
|
+
diffImagePath: string;
|
|
14
|
+
isMatch: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Compare two PNG images pixel-by-pixel using pixelmatch and generate a highlighted diff image.
|
|
18
|
+
*/
|
|
19
|
+
declare function comparePngImages(img1Path: string, img2Path: string, outDiffPath: string, options?: VisualDiffOptions): Promise<VisualDiffResult>;
|
|
20
|
+
|
|
21
|
+
interface RecordedDiffEntry {
|
|
22
|
+
name: string;
|
|
23
|
+
baselinePath: string;
|
|
24
|
+
currentPath: string;
|
|
25
|
+
diffPath: string;
|
|
26
|
+
result: VisualDiffResult;
|
|
27
|
+
}
|
|
28
|
+
declare const visualDiffPlugin: AgentLensPlugin;
|
|
29
|
+
|
|
30
|
+
export { type RecordedDiffEntry, type VisualDiffOptions, type VisualDiffResult, comparePngImages, visualDiffPlugin as default, visualDiffPlugin };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { A as AgentLensPlugin } from '../../dsl-BIjVN1M0.js';
|
|
2
|
+
import 'playwright';
|
|
3
|
+
|
|
4
|
+
interface VisualDiffOptions {
|
|
5
|
+
threshold?: number;
|
|
6
|
+
includeAA?: boolean;
|
|
7
|
+
diffColor?: [number, number, number];
|
|
8
|
+
}
|
|
9
|
+
interface VisualDiffResult {
|
|
10
|
+
diffPixels: number;
|
|
11
|
+
totalPixels: number;
|
|
12
|
+
diffPercent: number;
|
|
13
|
+
diffImagePath: string;
|
|
14
|
+
isMatch: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Compare two PNG images pixel-by-pixel using pixelmatch and generate a highlighted diff image.
|
|
18
|
+
*/
|
|
19
|
+
declare function comparePngImages(img1Path: string, img2Path: string, outDiffPath: string, options?: VisualDiffOptions): Promise<VisualDiffResult>;
|
|
20
|
+
|
|
21
|
+
interface RecordedDiffEntry {
|
|
22
|
+
name: string;
|
|
23
|
+
baselinePath: string;
|
|
24
|
+
currentPath: string;
|
|
25
|
+
diffPath: string;
|
|
26
|
+
result: VisualDiffResult;
|
|
27
|
+
}
|
|
28
|
+
declare const visualDiffPlugin: AgentLensPlugin;
|
|
29
|
+
|
|
30
|
+
export { type RecordedDiffEntry, type VisualDiffOptions, type VisualDiffResult, comparePngImages, visualDiffPlugin as default, visualDiffPlugin };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/plugins/visual-diff/index.ts
|
|
31
|
+
var visual_diff_exports = {};
|
|
32
|
+
__export(visual_diff_exports, {
|
|
33
|
+
comparePngImages: () => comparePngImages,
|
|
34
|
+
default: () => visual_diff_default,
|
|
35
|
+
visualDiffPlugin: () => visualDiffPlugin
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(visual_diff_exports);
|
|
38
|
+
var import_path = __toESM(require("path"));
|
|
39
|
+
|
|
40
|
+
// src/shared/api/plugin.ts
|
|
41
|
+
function definePlugin(plugin) {
|
|
42
|
+
return plugin;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/plugins/visual-diff/diff.ts
|
|
46
|
+
var import_fs = __toESM(require("fs"));
|
|
47
|
+
var import_pngjs = require("pngjs");
|
|
48
|
+
var import_pixelmatch = __toESM(require("pixelmatch"));
|
|
49
|
+
async function comparePngImages(img1Path, img2Path, outDiffPath, options) {
|
|
50
|
+
if (!import_fs.default.existsSync(img1Path)) {
|
|
51
|
+
throw new Error(`[visual-diff] Baseline image not found: ${img1Path}`);
|
|
52
|
+
}
|
|
53
|
+
if (!import_fs.default.existsSync(img2Path)) {
|
|
54
|
+
throw new Error(`[visual-diff] Target image not found: ${img2Path}`);
|
|
55
|
+
}
|
|
56
|
+
const img1Data = import_fs.default.readFileSync(img1Path);
|
|
57
|
+
const img2Data = import_fs.default.readFileSync(img2Path);
|
|
58
|
+
const img1 = import_pngjs.PNG.sync.read(img1Data);
|
|
59
|
+
const img2 = import_pngjs.PNG.sync.read(img2Data);
|
|
60
|
+
const width = Math.max(img1.width, img2.width);
|
|
61
|
+
const height = Math.max(img1.height, img2.height);
|
|
62
|
+
const padded1 = padImage(img1, width, height);
|
|
63
|
+
const padded2 = padImage(img2, width, height);
|
|
64
|
+
const diff = new import_pngjs.PNG({ width, height });
|
|
65
|
+
const diffPixels = (0, import_pixelmatch.default)(
|
|
66
|
+
padded1.data,
|
|
67
|
+
padded2.data,
|
|
68
|
+
diff.data,
|
|
69
|
+
width,
|
|
70
|
+
height,
|
|
71
|
+
{
|
|
72
|
+
threshold: options?.threshold ?? 0.1,
|
|
73
|
+
includeAA: options?.includeAA ?? false,
|
|
74
|
+
diffColor: options?.diffColor ?? [255, 0, 80]
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
const totalPixels = width * height;
|
|
78
|
+
const diffPercent = totalPixels > 0 ? diffPixels / totalPixels * 100 : 0;
|
|
79
|
+
import_fs.default.writeFileSync(outDiffPath, import_pngjs.PNG.sync.write(diff));
|
|
80
|
+
return {
|
|
81
|
+
diffPixels,
|
|
82
|
+
totalPixels,
|
|
83
|
+
diffPercent: Number(diffPercent.toFixed(2)),
|
|
84
|
+
diffImagePath: outDiffPath,
|
|
85
|
+
isMatch: diffPixels === 0
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function padImage(src, width, height) {
|
|
89
|
+
if (src.width === width && src.height === height) {
|
|
90
|
+
return src;
|
|
91
|
+
}
|
|
92
|
+
const padded = new import_pngjs.PNG({ width, height });
|
|
93
|
+
padded.data.fill(255);
|
|
94
|
+
import_pngjs.PNG.bitblt(src, padded, 0, 0, src.width, src.height, 0, 0);
|
|
95
|
+
return padded;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/plugins/visual-diff/index.ts
|
|
99
|
+
var STATE_DIFFS_KEY = "recordedVisualDiffs";
|
|
100
|
+
var visualDiffPlugin = definePlugin({
|
|
101
|
+
name: "visual-diff",
|
|
102
|
+
version: "1.0.0",
|
|
103
|
+
setup: (hookContext) => {
|
|
104
|
+
hookContext.state.set(STATE_DIFFS_KEY, []);
|
|
105
|
+
},
|
|
106
|
+
extendContext: (ctx, page, hookContext) => {
|
|
107
|
+
const diffRecords = hookContext.state.get(STATE_DIFFS_KEY) || [];
|
|
108
|
+
return {
|
|
109
|
+
compareSnapshots: async (currentPath, baselinePath, options) => {
|
|
110
|
+
const artifactsDir = hookContext.artifactsDir;
|
|
111
|
+
const baseName = import_path.default.basename(currentPath, ".png");
|
|
112
|
+
const diffFileName = `${baseName}_diff.png`;
|
|
113
|
+
const diffPath = import_path.default.join(artifactsDir, diffFileName);
|
|
114
|
+
console.log(`\u{1F3A8} [visual-diff] Comparing ${import_path.default.basename(currentPath)} against baseline...`);
|
|
115
|
+
const result = await comparePngImages(baselinePath, currentPath, diffPath, options);
|
|
116
|
+
console.log(
|
|
117
|
+
`\u{1F3A8} [visual-diff] Result: ${result.diffPixels} px changed (${result.diffPercent}%) | Diff: ${diffFileName}`
|
|
118
|
+
);
|
|
119
|
+
diffRecords.push({
|
|
120
|
+
name: baseName,
|
|
121
|
+
baselinePath,
|
|
122
|
+
currentPath,
|
|
123
|
+
diffPath,
|
|
124
|
+
result
|
|
125
|
+
});
|
|
126
|
+
return result;
|
|
127
|
+
},
|
|
128
|
+
captureAndCompare: async (name, baselinePath, captureOptions, diffOptions) => {
|
|
129
|
+
const snap = await ctx.capture(name, captureOptions);
|
|
130
|
+
return await ctx.compareSnapshots(snap.filePath, baselinePath, diffOptions);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
onAfterRun: (reportData, hookContext) => {
|
|
135
|
+
const diffs = hookContext.state.get(STATE_DIFFS_KEY) || [];
|
|
136
|
+
if (diffs.length === 0 || !reportData.customSections) return;
|
|
137
|
+
let table = `| Test Step | Baseline | Current | Diff Image | Changed Pixels | Status |
|
|
138
|
+
`;
|
|
139
|
+
table += `| :--- | :--- | :--- | :--- | :-: | :-: |
|
|
140
|
+
`;
|
|
141
|
+
for (const d of diffs) {
|
|
142
|
+
const relBaseline = import_path.default.relative(reportData.outputDir, d.baselinePath).replace(/\\/g, "/");
|
|
143
|
+
const relCurrent = import_path.default.relative(reportData.outputDir, d.currentPath).replace(/\\/g, "/");
|
|
144
|
+
const relDiff = import_path.default.relative(reportData.outputDir, d.diffPath).replace(/\\/g, "/");
|
|
145
|
+
const status = d.result.diffPercent === 0 ? "\u2705 Perfect Match" : d.result.diffPercent < 1 ? `\u{1F7E1} Minor Shift (${d.result.diffPercent}%)` : `\u{1F534} Regressed (${d.result.diffPercent}%)`;
|
|
146
|
+
table += `| **${d.name}** | [Baseline](${relBaseline}) | [Current](${relCurrent}) | [Diff](${relDiff}) | ${d.result.diffPixels} px | ${status} |
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
reportData.customSections.push({
|
|
150
|
+
title: "\u{1F3A8} Visual Regression Diff (Pixelmatch)",
|
|
151
|
+
content: `> Automated pixel-by-pixel visual difference detector.
|
|
152
|
+
|
|
153
|
+
${table}`
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
var visual_diff_default = visualDiffPlugin;
|
|
158
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
159
|
+
0 && (module.exports = {
|
|
160
|
+
comparePngImages,
|
|
161
|
+
visualDiffPlugin
|
|
162
|
+
});
|
|
163
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/plugins/visual-diff/index.ts","../../../src/shared/api/plugin.ts","../../../src/plugins/visual-diff/diff.ts"],"sourcesContent":["import path from 'path';\nimport { definePlugin, type AgentLensPlugin } from '../../shared/api/plugin';\nimport { comparePngImages, type VisualDiffOptions, type VisualDiffResult } from './diff';\nimport type { CaptureOptions } from '../../shared/api/dsl';\n\nexport { comparePngImages, type VisualDiffOptions, type VisualDiffResult };\n\nconst STATE_DIFFS_KEY = 'recordedVisualDiffs';\n\nexport interface RecordedDiffEntry {\n name: string;\n baselinePath: string;\n currentPath: string;\n diffPath: string;\n result: VisualDiffResult;\n}\n\nexport const visualDiffPlugin: AgentLensPlugin = definePlugin({\n name: 'visual-diff',\n version: '1.0.0',\n\n setup: (hookContext) => {\n hookContext.state.set(STATE_DIFFS_KEY, [] as RecordedDiffEntry[]);\n },\n\n extendContext: (ctx, page, hookContext) => {\n const diffRecords = (hookContext.state.get(STATE_DIFFS_KEY) as RecordedDiffEntry[]) || [];\n\n return {\n compareSnapshots: async (\n currentPath: string,\n baselinePath: string,\n options?: VisualDiffOptions\n ): Promise<VisualDiffResult> => {\n const artifactsDir = hookContext.artifactsDir;\n const baseName = path.basename(currentPath, '.png');\n const diffFileName = `${baseName}_diff.png`;\n const diffPath = path.join(artifactsDir, diffFileName);\n\n console.log(`🎨 [visual-diff] Comparing ${path.basename(currentPath)} against baseline...`);\n const result = await comparePngImages(baselinePath, currentPath, diffPath, options);\n\n console.log(\n `🎨 [visual-diff] Result: ${result.diffPixels} px changed (${result.diffPercent}%) | Diff: ${diffFileName}`\n );\n\n diffRecords.push({\n name: baseName,\n baselinePath,\n currentPath,\n diffPath,\n result\n });\n\n return result;\n },\n\n captureAndCompare: async (\n name: string,\n baselinePath: string,\n captureOptions?: CaptureOptions,\n diffOptions?: VisualDiffOptions\n ): Promise<VisualDiffResult> => {\n const snap = await ctx.capture(name, captureOptions);\n return await ctx.compareSnapshots(snap.filePath, baselinePath, diffOptions);\n }\n };\n },\n\n onAfterRun: (reportData, hookContext) => {\n const diffs = (hookContext.state.get(STATE_DIFFS_KEY) as RecordedDiffEntry[]) || [];\n if (diffs.length === 0 || !reportData.customSections) return;\n\n let table = `| Test Step | Baseline | Current | Diff Image | Changed Pixels | Status |\\n`;\n table += `| :--- | :--- | :--- | :--- | :-: | :-: |\\n`;\n\n for (const d of diffs) {\n const relBaseline = path.relative(reportData.outputDir, d.baselinePath).replace(/\\\\/g, '/');\n const relCurrent = path.relative(reportData.outputDir, d.currentPath).replace(/\\\\/g, '/');\n const relDiff = path.relative(reportData.outputDir, d.diffPath).replace(/\\\\/g, '/');\n\n const status = d.result.diffPercent === 0\n ? '✅ Perfect Match'\n : d.result.diffPercent < 1.0\n ? `🟡 Minor Shift (${d.result.diffPercent}%)`\n : `🔴 Regressed (${d.result.diffPercent}%)`;\n\n table += `| **${d.name}** | [Baseline](${relBaseline}) | [Current](${relCurrent}) | [Diff](${relDiff}) | ${d.result.diffPixels} px | ${status} |\\n`;\n }\n\n reportData.customSections.push({\n title: '🎨 Visual Regression Diff (Pixelmatch)',\n content: `> Automated pixel-by-pixel visual difference detector.\\n\\n${table}`\n });\n }\n});\n\nexport default visualDiffPlugin;\n","import type { Page, BrowserContext, Browser } from 'playwright';\nimport type { TestContext, VisualScenario } from './dsl';\nimport type { ReportData } from '../types/report';\n\nexport interface DriverLaunchResult {\n page: Page;\n context: BrowserContext;\n browser?: Browser;\n stop?: () => Promise<void>;\n}\n\nexport interface PluginHookContext {\n scenario?: VisualScenario;\n targetMode: 'desktop' | 'preview' | string;\n artifactsDir: string;\n cliOptions?: Record<string, unknown>;\n state: Map<string, unknown>;\n}\n\nexport interface AgentLensPlugin {\n /** Unique plugin identifier (e.g. 'desktop-webview2', 'live-controller', 'mock-ipc') */\n name: string;\n version?: string;\n\n /** Lifecycle hook: initial setup before drivers and scenarios start */\n setup?: (context: PluginHookContext) => Promise<void> | void;\n\n /**\n * Microkernel driver hook: allows a plugin to provide a custom browser/page session\n * (e.g., desktop WebView2 CDP connection, custom remote browser).\n */\n launchSession?: (\n options: { currentViewport: { width: number; height: number }; headed?: boolean },\n hookContext: PluginHookContext\n ) => Promise<DriverLaunchResult | undefined>;\n\n /** Lifecycle hook: called when browser context is ready */\n onContextCreated?: (context: BrowserContext, hookContext: PluginHookContext) => Promise<void> | void;\n\n /** Lifecycle hook: called when the test page is created/navigated */\n onPageCreated?: (page: Page, context: BrowserContext, hookContext: PluginHookContext) => Promise<void> | void;\n\n /**\n * Context extension: inject custom methods or properties directly into TestContext (ctx)\n */\n extendContext?: (\n ctx: TestContext,\n page: Page,\n hookContext: PluginHookContext\n ) => Record<string, any> | Promise<Record<string, any>>;\n\n /** Lifecycle hook: called after scenario runs and report data is calculated */\n onAfterRun?: (reportData: ReportData, hookContext: PluginHookContext) => Promise<void> | void;\n\n /** Lifecycle hook: teardown and cleanup guaranteed to run */\n teardown?: (hookContext: PluginHookContext) => Promise<void> | void;\n}\n\nexport function definePlugin(plugin: AgentLensPlugin): AgentLensPlugin {\n return plugin;\n}","import fs from 'fs';\nimport { PNG } from 'pngjs';\nimport pixelmatch from 'pixelmatch';\n\nexport interface VisualDiffOptions {\n threshold?: number; // 0 to 1 (default 0.1)\n includeAA?: boolean; // include anti-aliasing pixels\n diffColor?: [number, number, number]; // RGB\n}\n\nexport interface VisualDiffResult {\n diffPixels: number;\n totalPixels: number;\n diffPercent: number;\n diffImagePath: string;\n isMatch: boolean;\n}\n\n/**\n * Compare two PNG images pixel-by-pixel using pixelmatch and generate a highlighted diff image.\n */\nexport async function comparePngImages(\n img1Path: string,\n img2Path: string,\n outDiffPath: string,\n options?: VisualDiffOptions\n): Promise<VisualDiffResult> {\n if (!fs.existsSync(img1Path)) {\n throw new Error(`[visual-diff] Baseline image not found: ${img1Path}`);\n }\n if (!fs.existsSync(img2Path)) {\n throw new Error(`[visual-diff] Target image not found: ${img2Path}`);\n }\n\n const img1Data = fs.readFileSync(img1Path);\n const img2Data = fs.readFileSync(img2Path);\n\n const img1 = PNG.sync.read(img1Data);\n const img2 = PNG.sync.read(img2Data);\n\n const width = Math.max(img1.width, img2.width);\n const height = Math.max(img1.height, img2.height);\n\n // Resize / pad images to match dimensions if they differ\n const padded1 = padImage(img1, width, height);\n const padded2 = padImage(img2, width, height);\n\n const diff = new PNG({ width, height });\n\n const diffPixels = pixelmatch(\n padded1.data,\n padded2.data,\n diff.data,\n width,\n height,\n {\n threshold: options?.threshold ?? 0.1,\n includeAA: options?.includeAA ?? false,\n diffColor: options?.diffColor ?? [255, 0, 80]\n }\n );\n\n const totalPixels = width * height;\n const diffPercent = totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0;\n\n fs.writeFileSync(outDiffPath, PNG.sync.write(diff));\n\n return {\n diffPixels,\n totalPixels,\n diffPercent: Number(diffPercent.toFixed(2)),\n diffImagePath: outDiffPath,\n isMatch: diffPixels === 0\n };\n}\n\nfunction padImage(src: PNG, width: number, height: number): PNG {\n if (src.width === width && src.height === height) {\n return src;\n }\n\n const padded = new PNG({ width, height });\n // Fill with white background\n padded.data.fill(255);\n\n PNG.bitblt(src, padded, 0, 0, src.width, src.height, 0, 0);\n return padded;\n}\n"],"mappings":"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","names":["fs","pixelmatch","path"]}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// src/plugins/visual-diff/index.ts
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
// src/shared/api/plugin.ts
|
|
5
|
+
function definePlugin(plugin) {
|
|
6
|
+
return plugin;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// src/plugins/visual-diff/diff.ts
|
|
10
|
+
import fs from "fs";
|
|
11
|
+
import { PNG } from "pngjs";
|
|
12
|
+
import pixelmatch from "pixelmatch";
|
|
13
|
+
async function comparePngImages(img1Path, img2Path, outDiffPath, options) {
|
|
14
|
+
if (!fs.existsSync(img1Path)) {
|
|
15
|
+
throw new Error(`[visual-diff] Baseline image not found: ${img1Path}`);
|
|
16
|
+
}
|
|
17
|
+
if (!fs.existsSync(img2Path)) {
|
|
18
|
+
throw new Error(`[visual-diff] Target image not found: ${img2Path}`);
|
|
19
|
+
}
|
|
20
|
+
const img1Data = fs.readFileSync(img1Path);
|
|
21
|
+
const img2Data = fs.readFileSync(img2Path);
|
|
22
|
+
const img1 = PNG.sync.read(img1Data);
|
|
23
|
+
const img2 = PNG.sync.read(img2Data);
|
|
24
|
+
const width = Math.max(img1.width, img2.width);
|
|
25
|
+
const height = Math.max(img1.height, img2.height);
|
|
26
|
+
const padded1 = padImage(img1, width, height);
|
|
27
|
+
const padded2 = padImage(img2, width, height);
|
|
28
|
+
const diff = new PNG({ width, height });
|
|
29
|
+
const diffPixels = pixelmatch(
|
|
30
|
+
padded1.data,
|
|
31
|
+
padded2.data,
|
|
32
|
+
diff.data,
|
|
33
|
+
width,
|
|
34
|
+
height,
|
|
35
|
+
{
|
|
36
|
+
threshold: options?.threshold ?? 0.1,
|
|
37
|
+
includeAA: options?.includeAA ?? false,
|
|
38
|
+
diffColor: options?.diffColor ?? [255, 0, 80]
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
const totalPixels = width * height;
|
|
42
|
+
const diffPercent = totalPixels > 0 ? diffPixels / totalPixels * 100 : 0;
|
|
43
|
+
fs.writeFileSync(outDiffPath, PNG.sync.write(diff));
|
|
44
|
+
return {
|
|
45
|
+
diffPixels,
|
|
46
|
+
totalPixels,
|
|
47
|
+
diffPercent: Number(diffPercent.toFixed(2)),
|
|
48
|
+
diffImagePath: outDiffPath,
|
|
49
|
+
isMatch: diffPixels === 0
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function padImage(src, width, height) {
|
|
53
|
+
if (src.width === width && src.height === height) {
|
|
54
|
+
return src;
|
|
55
|
+
}
|
|
56
|
+
const padded = new PNG({ width, height });
|
|
57
|
+
padded.data.fill(255);
|
|
58
|
+
PNG.bitblt(src, padded, 0, 0, src.width, src.height, 0, 0);
|
|
59
|
+
return padded;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/plugins/visual-diff/index.ts
|
|
63
|
+
var STATE_DIFFS_KEY = "recordedVisualDiffs";
|
|
64
|
+
var visualDiffPlugin = definePlugin({
|
|
65
|
+
name: "visual-diff",
|
|
66
|
+
version: "1.0.0",
|
|
67
|
+
setup: (hookContext) => {
|
|
68
|
+
hookContext.state.set(STATE_DIFFS_KEY, []);
|
|
69
|
+
},
|
|
70
|
+
extendContext: (ctx, page, hookContext) => {
|
|
71
|
+
const diffRecords = hookContext.state.get(STATE_DIFFS_KEY) || [];
|
|
72
|
+
return {
|
|
73
|
+
compareSnapshots: async (currentPath, baselinePath, options) => {
|
|
74
|
+
const artifactsDir = hookContext.artifactsDir;
|
|
75
|
+
const baseName = path.basename(currentPath, ".png");
|
|
76
|
+
const diffFileName = `${baseName}_diff.png`;
|
|
77
|
+
const diffPath = path.join(artifactsDir, diffFileName);
|
|
78
|
+
console.log(`\u{1F3A8} [visual-diff] Comparing ${path.basename(currentPath)} against baseline...`);
|
|
79
|
+
const result = await comparePngImages(baselinePath, currentPath, diffPath, options);
|
|
80
|
+
console.log(
|
|
81
|
+
`\u{1F3A8} [visual-diff] Result: ${result.diffPixels} px changed (${result.diffPercent}%) | Diff: ${diffFileName}`
|
|
82
|
+
);
|
|
83
|
+
diffRecords.push({
|
|
84
|
+
name: baseName,
|
|
85
|
+
baselinePath,
|
|
86
|
+
currentPath,
|
|
87
|
+
diffPath,
|
|
88
|
+
result
|
|
89
|
+
});
|
|
90
|
+
return result;
|
|
91
|
+
},
|
|
92
|
+
captureAndCompare: async (name, baselinePath, captureOptions, diffOptions) => {
|
|
93
|
+
const snap = await ctx.capture(name, captureOptions);
|
|
94
|
+
return await ctx.compareSnapshots(snap.filePath, baselinePath, diffOptions);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
onAfterRun: (reportData, hookContext) => {
|
|
99
|
+
const diffs = hookContext.state.get(STATE_DIFFS_KEY) || [];
|
|
100
|
+
if (diffs.length === 0 || !reportData.customSections) return;
|
|
101
|
+
let table = `| Test Step | Baseline | Current | Diff Image | Changed Pixels | Status |
|
|
102
|
+
`;
|
|
103
|
+
table += `| :--- | :--- | :--- | :--- | :-: | :-: |
|
|
104
|
+
`;
|
|
105
|
+
for (const d of diffs) {
|
|
106
|
+
const relBaseline = path.relative(reportData.outputDir, d.baselinePath).replace(/\\/g, "/");
|
|
107
|
+
const relCurrent = path.relative(reportData.outputDir, d.currentPath).replace(/\\/g, "/");
|
|
108
|
+
const relDiff = path.relative(reportData.outputDir, d.diffPath).replace(/\\/g, "/");
|
|
109
|
+
const status = d.result.diffPercent === 0 ? "\u2705 Perfect Match" : d.result.diffPercent < 1 ? `\u{1F7E1} Minor Shift (${d.result.diffPercent}%)` : `\u{1F534} Regressed (${d.result.diffPercent}%)`;
|
|
110
|
+
table += `| **${d.name}** | [Baseline](${relBaseline}) | [Current](${relCurrent}) | [Diff](${relDiff}) | ${d.result.diffPixels} px | ${status} |
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
reportData.customSections.push({
|
|
114
|
+
title: "\u{1F3A8} Visual Regression Diff (Pixelmatch)",
|
|
115
|
+
content: `> Automated pixel-by-pixel visual difference detector.
|
|
116
|
+
|
|
117
|
+
${table}`
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
var visual_diff_default = visualDiffPlugin;
|
|
122
|
+
export {
|
|
123
|
+
comparePngImages,
|
|
124
|
+
visual_diff_default as default,
|
|
125
|
+
visualDiffPlugin
|
|
126
|
+
};
|
|
127
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/plugins/visual-diff/index.ts","../../../src/shared/api/plugin.ts","../../../src/plugins/visual-diff/diff.ts"],"sourcesContent":["import path from 'path';\nimport { definePlugin, type AgentLensPlugin } from '../../shared/api/plugin';\nimport { comparePngImages, type VisualDiffOptions, type VisualDiffResult } from './diff';\nimport type { CaptureOptions } from '../../shared/api/dsl';\n\nexport { comparePngImages, type VisualDiffOptions, type VisualDiffResult };\n\nconst STATE_DIFFS_KEY = 'recordedVisualDiffs';\n\nexport interface RecordedDiffEntry {\n name: string;\n baselinePath: string;\n currentPath: string;\n diffPath: string;\n result: VisualDiffResult;\n}\n\nexport const visualDiffPlugin: AgentLensPlugin = definePlugin({\n name: 'visual-diff',\n version: '1.0.0',\n\n setup: (hookContext) => {\n hookContext.state.set(STATE_DIFFS_KEY, [] as RecordedDiffEntry[]);\n },\n\n extendContext: (ctx, page, hookContext) => {\n const diffRecords = (hookContext.state.get(STATE_DIFFS_KEY) as RecordedDiffEntry[]) || [];\n\n return {\n compareSnapshots: async (\n currentPath: string,\n baselinePath: string,\n options?: VisualDiffOptions\n ): Promise<VisualDiffResult> => {\n const artifactsDir = hookContext.artifactsDir;\n const baseName = path.basename(currentPath, '.png');\n const diffFileName = `${baseName}_diff.png`;\n const diffPath = path.join(artifactsDir, diffFileName);\n\n console.log(`🎨 [visual-diff] Comparing ${path.basename(currentPath)} against baseline...`);\n const result = await comparePngImages(baselinePath, currentPath, diffPath, options);\n\n console.log(\n `🎨 [visual-diff] Result: ${result.diffPixels} px changed (${result.diffPercent}%) | Diff: ${diffFileName}`\n );\n\n diffRecords.push({\n name: baseName,\n baselinePath,\n currentPath,\n diffPath,\n result\n });\n\n return result;\n },\n\n captureAndCompare: async (\n name: string,\n baselinePath: string,\n captureOptions?: CaptureOptions,\n diffOptions?: VisualDiffOptions\n ): Promise<VisualDiffResult> => {\n const snap = await ctx.capture(name, captureOptions);\n return await ctx.compareSnapshots(snap.filePath, baselinePath, diffOptions);\n }\n };\n },\n\n onAfterRun: (reportData, hookContext) => {\n const diffs = (hookContext.state.get(STATE_DIFFS_KEY) as RecordedDiffEntry[]) || [];\n if (diffs.length === 0 || !reportData.customSections) return;\n\n let table = `| Test Step | Baseline | Current | Diff Image | Changed Pixels | Status |\\n`;\n table += `| :--- | :--- | :--- | :--- | :-: | :-: |\\n`;\n\n for (const d of diffs) {\n const relBaseline = path.relative(reportData.outputDir, d.baselinePath).replace(/\\\\/g, '/');\n const relCurrent = path.relative(reportData.outputDir, d.currentPath).replace(/\\\\/g, '/');\n const relDiff = path.relative(reportData.outputDir, d.diffPath).replace(/\\\\/g, '/');\n\n const status = d.result.diffPercent === 0\n ? '✅ Perfect Match'\n : d.result.diffPercent < 1.0\n ? `🟡 Minor Shift (${d.result.diffPercent}%)`\n : `🔴 Regressed (${d.result.diffPercent}%)`;\n\n table += `| **${d.name}** | [Baseline](${relBaseline}) | [Current](${relCurrent}) | [Diff](${relDiff}) | ${d.result.diffPixels} px | ${status} |\\n`;\n }\n\n reportData.customSections.push({\n title: '🎨 Visual Regression Diff (Pixelmatch)',\n content: `> Automated pixel-by-pixel visual difference detector.\\n\\n${table}`\n });\n }\n});\n\nexport default visualDiffPlugin;\n","import type { Page, BrowserContext, Browser } from 'playwright';\nimport type { TestContext, VisualScenario } from './dsl';\nimport type { ReportData } from '../types/report';\n\nexport interface DriverLaunchResult {\n page: Page;\n context: BrowserContext;\n browser?: Browser;\n stop?: () => Promise<void>;\n}\n\nexport interface PluginHookContext {\n scenario?: VisualScenario;\n targetMode: 'desktop' | 'preview' | string;\n artifactsDir: string;\n cliOptions?: Record<string, unknown>;\n state: Map<string, unknown>;\n}\n\nexport interface AgentLensPlugin {\n /** Unique plugin identifier (e.g. 'desktop-webview2', 'live-controller', 'mock-ipc') */\n name: string;\n version?: string;\n\n /** Lifecycle hook: initial setup before drivers and scenarios start */\n setup?: (context: PluginHookContext) => Promise<void> | void;\n\n /**\n * Microkernel driver hook: allows a plugin to provide a custom browser/page session\n * (e.g., desktop WebView2 CDP connection, custom remote browser).\n */\n launchSession?: (\n options: { currentViewport: { width: number; height: number }; headed?: boolean },\n hookContext: PluginHookContext\n ) => Promise<DriverLaunchResult | undefined>;\n\n /** Lifecycle hook: called when browser context is ready */\n onContextCreated?: (context: BrowserContext, hookContext: PluginHookContext) => Promise<void> | void;\n\n /** Lifecycle hook: called when the test page is created/navigated */\n onPageCreated?: (page: Page, context: BrowserContext, hookContext: PluginHookContext) => Promise<void> | void;\n\n /**\n * Context extension: inject custom methods or properties directly into TestContext (ctx)\n */\n extendContext?: (\n ctx: TestContext,\n page: Page,\n hookContext: PluginHookContext\n ) => Record<string, any> | Promise<Record<string, any>>;\n\n /** Lifecycle hook: called after scenario runs and report data is calculated */\n onAfterRun?: (reportData: ReportData, hookContext: PluginHookContext) => Promise<void> | void;\n\n /** Lifecycle hook: teardown and cleanup guaranteed to run */\n teardown?: (hookContext: PluginHookContext) => Promise<void> | void;\n}\n\nexport function definePlugin(plugin: AgentLensPlugin): AgentLensPlugin {\n return plugin;\n}","import fs from 'fs';\nimport { PNG } from 'pngjs';\nimport pixelmatch from 'pixelmatch';\n\nexport interface VisualDiffOptions {\n threshold?: number; // 0 to 1 (default 0.1)\n includeAA?: boolean; // include anti-aliasing pixels\n diffColor?: [number, number, number]; // RGB\n}\n\nexport interface VisualDiffResult {\n diffPixels: number;\n totalPixels: number;\n diffPercent: number;\n diffImagePath: string;\n isMatch: boolean;\n}\n\n/**\n * Compare two PNG images pixel-by-pixel using pixelmatch and generate a highlighted diff image.\n */\nexport async function comparePngImages(\n img1Path: string,\n img2Path: string,\n outDiffPath: string,\n options?: VisualDiffOptions\n): Promise<VisualDiffResult> {\n if (!fs.existsSync(img1Path)) {\n throw new Error(`[visual-diff] Baseline image not found: ${img1Path}`);\n }\n if (!fs.existsSync(img2Path)) {\n throw new Error(`[visual-diff] Target image not found: ${img2Path}`);\n }\n\n const img1Data = fs.readFileSync(img1Path);\n const img2Data = fs.readFileSync(img2Path);\n\n const img1 = PNG.sync.read(img1Data);\n const img2 = PNG.sync.read(img2Data);\n\n const width = Math.max(img1.width, img2.width);\n const height = Math.max(img1.height, img2.height);\n\n // Resize / pad images to match dimensions if they differ\n const padded1 = padImage(img1, width, height);\n const padded2 = padImage(img2, width, height);\n\n const diff = new PNG({ width, height });\n\n const diffPixels = pixelmatch(\n padded1.data,\n padded2.data,\n diff.data,\n width,\n height,\n {\n threshold: options?.threshold ?? 0.1,\n includeAA: options?.includeAA ?? false,\n diffColor: options?.diffColor ?? [255, 0, 80]\n }\n );\n\n const totalPixels = width * height;\n const diffPercent = totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0;\n\n fs.writeFileSync(outDiffPath, PNG.sync.write(diff));\n\n return {\n diffPixels,\n totalPixels,\n diffPercent: Number(diffPercent.toFixed(2)),\n diffImagePath: outDiffPath,\n isMatch: diffPixels === 0\n };\n}\n\nfunction padImage(src: PNG, width: number, height: number): PNG {\n if (src.width === width && src.height === height) {\n return src;\n }\n\n const padded = new PNG({ width, height });\n // Fill with white background\n padded.data.fill(255);\n\n PNG.bitblt(src, padded, 0, 0, src.width, src.height, 0, 0);\n return padded;\n}\n"],"mappings":"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","names":[]}
|