argus-reviewer-e2e 0.1.2

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/action/action.yml +147 -0
  4. package/action/sticky-comment.mjs +376 -0
  5. package/dist/api.d.ts +121 -0
  6. package/dist/api.js +256 -0
  7. package/dist/cache/fingerprint.d.ts +59 -0
  8. package/dist/cache/fingerprint.js +67 -0
  9. package/dist/cache/store.d.ts +17 -0
  10. package/dist/cache/store.js +41 -0
  11. package/dist/cli.d.ts +25 -0
  12. package/dist/cli.js +1355 -0
  13. package/dist/config.d.ts +130 -0
  14. package/dist/config.js +163 -0
  15. package/dist/debug.d.ts +1 -0
  16. package/dist/debug.js +30 -0
  17. package/dist/detect.d.ts +50 -0
  18. package/dist/detect.js +105 -0
  19. package/dist/driver/browser.d.ts +64 -0
  20. package/dist/driver/browser.js +200 -0
  21. package/dist/driver/target.d.ts +23 -0
  22. package/dist/driver/target.js +97 -0
  23. package/dist/engine/actions.d.ts +26 -0
  24. package/dist/engine/actions.js +47 -0
  25. package/dist/engine/loop.d.ts +118 -0
  26. package/dist/engine/loop.js +649 -0
  27. package/dist/engine/prompts.d.ts +22 -0
  28. package/dist/engine/prompts.js +112 -0
  29. package/dist/evidence/ci.d.ts +18 -0
  30. package/dist/evidence/ci.js +61 -0
  31. package/dist/evidence/link.d.ts +35 -0
  32. package/dist/evidence/link.js +90 -0
  33. package/dist/executor/a0.d.ts +29 -0
  34. package/dist/executor/a0.js +38 -0
  35. package/dist/fsutil.d.ts +5 -0
  36. package/dist/fsutil.js +12 -0
  37. package/dist/index/context.d.ts +17 -0
  38. package/dist/index/context.js +88 -0
  39. package/dist/index/diff.d.ts +1 -0
  40. package/dist/index/diff.js +33 -0
  41. package/dist/index/invalidate.d.ts +28 -0
  42. package/dist/index/invalidate.js +56 -0
  43. package/dist/index/scan.d.ts +26 -0
  44. package/dist/index/scan.js +209 -0
  45. package/dist/journal/build.d.ts +15 -0
  46. package/dist/journal/build.js +47 -0
  47. package/dist/journal/schema.d.ts +59 -0
  48. package/dist/journal/schema.js +6 -0
  49. package/dist/journal/store.d.ts +10 -0
  50. package/dist/journal/store.js +26 -0
  51. package/dist/live.d.ts +2 -0
  52. package/dist/live.js +46 -0
  53. package/dist/log.d.ts +17 -0
  54. package/dist/log.js +24 -0
  55. package/dist/report/comment.d.ts +13 -0
  56. package/dist/report/comment.js +135 -0
  57. package/dist/report/junit.d.ts +10 -0
  58. package/dist/report/junit.js +46 -0
  59. package/dist/report/run.d.ts +51 -0
  60. package/dist/report/run.js +36 -0
  61. package/dist/vision/cost.d.ts +36 -0
  62. package/dist/vision/cost.js +16 -0
  63. package/dist/vision/ledger.d.ts +29 -0
  64. package/dist/vision/ledger.js +65 -0
  65. package/dist/vision/openrouter.d.ts +70 -0
  66. package/dist/vision/openrouter.js +134 -0
  67. package/package.json +65 -0
@@ -0,0 +1,200 @@
1
+ import { mkdtemp, mkdir, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { debug } from '../debug.js';
5
+ import { chromium, firefox, webkit } from 'playwright';
6
+ const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
7
+ const DEFAULT_QUALITY = 70;
8
+ /**
9
+ * One Playwright context per run. Viewport and deviceScaleFactor are pinned so
10
+ * vision-model pixel coordinates map 1:1 to viewport pixels (KTD2).
11
+ */
12
+ export class BrowserDriver {
13
+ browser;
14
+ context;
15
+ page;
16
+ quality;
17
+ videoDir;
18
+ viewport;
19
+ browserTimeoutMs;
20
+ video;
21
+ closed;
22
+ constructor(browser, context, page, quality, videoDir, viewport, browserTimeoutMs, video, closed = false) {
23
+ this.browser = browser;
24
+ this.context = context;
25
+ this.page = page;
26
+ this.quality = quality;
27
+ this.videoDir = videoDir;
28
+ this.viewport = viewport;
29
+ this.browserTimeoutMs = browserTimeoutMs;
30
+ this.video = video;
31
+ this.closed = closed;
32
+ }
33
+ static async launch(options = {}) {
34
+ const viewport = options.viewport ?? DEFAULT_VIEWPORT;
35
+ const browserName = options.browser ?? 'chromium';
36
+ const browserType = { chromium, firefox, webkit }[browserName];
37
+ if (browserType === undefined) {
38
+ throw new Error(`unknown browser: ${browserName}`);
39
+ }
40
+ const ownsVideoDir = options.videoDir === undefined;
41
+ const videoDir = options.videoDir ?? (await mkdtemp(join(tmpdir(), 'argus-video-')));
42
+ await mkdir(videoDir, { recursive: true });
43
+ const cleanupVideoDir = () => ownsVideoDir ? rm(videoDir, { recursive: true, force: true }).catch(() => undefined) : Promise.resolve();
44
+ let browser;
45
+ try {
46
+ browser = await browserType.launch({ headless: true });
47
+ }
48
+ catch (e) {
49
+ await cleanupVideoDir();
50
+ const msg = e.message;
51
+ if (/executable doesn't exist|browser has not been installed/i.test(msg)) {
52
+ throw new Error(`${msg}\nHint: install it with \`npx playwright install ${browserName}\``, {
53
+ cause: e,
54
+ });
55
+ }
56
+ throw e;
57
+ }
58
+ try {
59
+ const context = await browser.newContext({
60
+ viewport,
61
+ deviceScaleFactor: 1,
62
+ recordVideo: { dir: videoDir, size: viewport },
63
+ });
64
+ const page = await context.newPage();
65
+ return new BrowserDriver(browser, context, page, options.screenshotQuality ?? DEFAULT_QUALITY, videoDir, viewport, options.browserTimeoutMs ?? 30_000, undefined);
66
+ }
67
+ catch (e) {
68
+ await browser.close().catch(() => undefined);
69
+ await cleanupVideoDir();
70
+ throw e;
71
+ }
72
+ }
73
+ get rawPage() {
74
+ return this.page;
75
+ }
76
+ get recordingDir() {
77
+ return this.videoDir;
78
+ }
79
+ async goto(url) {
80
+ await this.page.goto(url, { waitUntil: 'load' });
81
+ }
82
+ /**
83
+ * Capture the current observation: a bounded JPEG screenshot plus the page's
84
+ * a11y tree as YAML via ariaSnapshot (not the deprecated accessibility API).
85
+ *
86
+ * `grid: true` paints a temporary coordinate overlay (lines + axis labels
87
+ * every 100px) before the screenshot and removes it immediately after — the
88
+ * set-of-marks trick that measurably improves vision-model pixel grounding.
89
+ */
90
+ async observe(options = {}) {
91
+ const grid = options.grid === true;
92
+ let painted = false;
93
+ if (grid) {
94
+ try {
95
+ await this._removeGrid(); // stale overlay from a failed prior paint
96
+ await this._paintGrid();
97
+ painted = true;
98
+ }
99
+ catch {
100
+ // Navigation race (execution context destroyed) or no document body —
101
+ // degrade to an ungridded observation rather than aborting the step.
102
+ }
103
+ }
104
+ try {
105
+ const screenshotJpeg = await this.page.screenshot({
106
+ type: 'jpeg',
107
+ quality: this.quality,
108
+ scale: 'css',
109
+ });
110
+ const a11yYaml = await this.page.locator('body').ariaSnapshot();
111
+ return { screenshotJpeg, a11yYaml, width: this.viewport.width, height: this.viewport.height };
112
+ }
113
+ finally {
114
+ if (painted)
115
+ await this._removeGrid();
116
+ }
117
+ }
118
+ async _paintGrid() {
119
+ await this.page.evaluate(() => {
120
+ const doc = globalThis.document;
121
+ const overlay = doc.createElement('div');
122
+ overlay.id = '__argus_grid';
123
+ overlay.setAttribute('aria-hidden', 'true');
124
+ overlay.style.cssText =
125
+ 'position:fixed;inset:0;z-index:2147483647;pointer-events:none;' +
126
+ 'background-image:' +
127
+ 'linear-gradient(to right, rgba(255,0,0,.35) 1px, transparent 1px),' +
128
+ 'linear-gradient(to bottom, rgba(255,0,0,.35) 1px, transparent 1px);' +
129
+ 'background-size:100px 100px;';
130
+ doc.body.appendChild(overlay);
131
+ const labels = doc.createElement('div');
132
+ labels.id = '__argus_grid_labels';
133
+ labels.setAttribute('aria-hidden', 'true');
134
+ labels.style.cssText =
135
+ 'position:fixed;inset:0;z-index:2147483647;pointer-events:none;' +
136
+ 'font:9px monospace;color:rgba(200,0,0,.9);';
137
+ const win = globalThis;
138
+ for (let x = 100; x < win.innerWidth; x += 100) {
139
+ for (let y = 100; y < win.innerHeight; y += 100) {
140
+ const tag = win.document.createElement('span');
141
+ tag.style.cssText = `position:absolute;left:${x + 1}px;top:${y + 1}px;`;
142
+ tag.textContent = `${x},${y}`;
143
+ labels.appendChild(tag);
144
+ }
145
+ }
146
+ doc.body.appendChild(labels);
147
+ });
148
+ }
149
+ async _removeGrid() {
150
+ await this.page
151
+ .evaluate(() => {
152
+ const doc = globalThis.document;
153
+ for (const id of ['__argus_grid', '__argus_grid_labels']) {
154
+ doc.getElementById(id)?.remove();
155
+ }
156
+ })
157
+ .catch(() => undefined);
158
+ }
159
+ /** Path of the recorded webm, available after close(). */
160
+ videoPath() {
161
+ return this.video;
162
+ }
163
+ /** Close the context and browser; resolves the video artifact path. Idempotent. */
164
+ async close() {
165
+ if (this.closed)
166
+ return this.video;
167
+ this.closed = true;
168
+ const video = this.page.video();
169
+ try {
170
+ await this._withTimeout(this.context.close());
171
+ }
172
+ catch (e) {
173
+ debug('browser', `context close failed: ${e.message}`);
174
+ }
175
+ if (video) {
176
+ try {
177
+ this.video = await this._withTimeout(video.path());
178
+ }
179
+ catch {
180
+ this.video = undefined;
181
+ }
182
+ }
183
+ try {
184
+ await this._withTimeout(this.browser.close());
185
+ }
186
+ catch (e) {
187
+ debug('browser', `browser close failed: ${e.message}`);
188
+ }
189
+ return this.video;
190
+ }
191
+ _withTimeout(promise) {
192
+ return Promise.race([
193
+ promise,
194
+ new Promise((_, reject) => {
195
+ const t = setTimeout(() => reject(new Error('browser cleanup timed out')), this.browserTimeoutMs);
196
+ t.unref?.();
197
+ }),
198
+ ]);
199
+ }
200
+ }
@@ -0,0 +1,23 @@
1
+ import type { Target } from '../config.js';
2
+ /**
3
+ * Poll `url` until it answers with HTTP 2xx/3xx or the timeout elapses.
4
+ * Non-http(s) schemes (e.g. file://) cannot be fetched, so they are treated
5
+ * as immediately ready — Playwright navigates them directly.
6
+ */
7
+ export declare function waitForReady(url: string, timeoutMs: number): Promise<void>;
8
+ /**
9
+ * Boot adapter for the run target (R11): spawn a shell command, poll the URL
10
+ * until it answers with HTTP 2xx/3xx or the ready timeout elapses, then let
11
+ * the run proceed. stop() kills the whole spawned process tree.
12
+ */
13
+ export declare class TargetProcess {
14
+ private readonly child;
15
+ private readonly spec;
16
+ private stopped;
17
+ private constructor();
18
+ get url(): string;
19
+ get pid(): number | undefined;
20
+ static start(spec: Target): Promise<TargetProcess>;
21
+ /** Kill the spawned process tree (process group). Idempotent. */
22
+ stop(): Promise<void>;
23
+ }
@@ -0,0 +1,97 @@
1
+ import { spawn } from 'node:child_process';
2
+ const POLL_INTERVAL_MS = 250;
3
+ const STOP_GRACE_MS = 3_000;
4
+ /**
5
+ * Poll `url` until it answers with HTTP 2xx/3xx or the timeout elapses.
6
+ * Non-http(s) schemes (e.g. file://) cannot be fetched, so they are treated
7
+ * as immediately ready — Playwright navigates them directly.
8
+ */
9
+ export async function waitForReady(url, timeoutMs) {
10
+ if (!/^https?:/i.test(url))
11
+ return;
12
+ const deadline = Date.now() + timeoutMs;
13
+ for (;;) {
14
+ try {
15
+ const res = await fetch(url, { redirect: 'manual' });
16
+ if (res.status >= 200 && res.status < 400)
17
+ return;
18
+ }
19
+ catch {
20
+ // connection refused / not up yet — keep polling
21
+ }
22
+ if (Date.now() >= deadline) {
23
+ throw new Error(`Target did not become ready: ${url} did not respond ` +
24
+ `with HTTP 2xx/3xx within ${timeoutMs}ms`);
25
+ }
26
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
27
+ }
28
+ }
29
+ /**
30
+ * Boot adapter for the run target (R11): spawn a shell command, poll the URL
31
+ * until it answers with HTTP 2xx/3xx or the ready timeout elapses, then let
32
+ * the run proceed. stop() kills the whole spawned process tree.
33
+ */
34
+ export class TargetProcess {
35
+ child;
36
+ spec;
37
+ stopped = false;
38
+ constructor(child, spec) {
39
+ this.child = child;
40
+ this.spec = spec;
41
+ }
42
+ get url() {
43
+ return this.spec.url;
44
+ }
45
+ get pid() {
46
+ return this.child.pid;
47
+ }
48
+ static async start(spec) {
49
+ const child = spawn(spec.command, {
50
+ shell: true,
51
+ detached: true,
52
+ stdio: 'ignore',
53
+ });
54
+ const proc = new TargetProcess(child, spec);
55
+ const childExited = new Promise((_, reject) => {
56
+ child.once('error', (err) => reject(err));
57
+ child.once('exit', (code, signal) => reject(new Error(`Target command exited before ${spec.url} became ready ` +
58
+ `(code=${code ?? 'null'}, signal=${signal ?? 'null'})`)));
59
+ });
60
+ const ready = waitForReady(spec.url, spec.readyTimeoutMs);
61
+ try {
62
+ await Promise.race([ready, childExited]);
63
+ }
64
+ catch (e) {
65
+ await proc.stop();
66
+ throw e;
67
+ }
68
+ return proc;
69
+ }
70
+ /** Kill the spawned process tree (process group). Idempotent. */
71
+ async stop() {
72
+ if (this.stopped)
73
+ return;
74
+ this.stopped = true;
75
+ const exited = new Promise((resolve) => {
76
+ this.child.once('exit', () => resolve());
77
+ setTimeout(resolve, STOP_GRACE_MS).unref();
78
+ });
79
+ try {
80
+ // Negative pid kills the detached process group — the shell and its children.
81
+ if (this.child.pid !== undefined)
82
+ process.kill(-this.child.pid, 'SIGTERM');
83
+ }
84
+ catch {
85
+ // already gone
86
+ }
87
+ await exited;
88
+ try {
89
+ if (this.child.exitCode === null && this.child.pid !== undefined) {
90
+ process.kill(-this.child.pid, 'SIGKILL');
91
+ }
92
+ }
93
+ catch {
94
+ // already gone
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,26 @@
1
+ import type { BrowserDriver, Observation } from '../driver/browser.js';
2
+ /**
3
+ * Action primitives over the driver page. All coordinates are plain viewport
4
+ * pixels — the pinned viewport + deviceScaleFactor: 1 means model coordinates
5
+ * map 1:1 (KTD2). Every action returns a post-action observation so callers
6
+ * can verify the result immediately.
7
+ */
8
+ export declare class Actions {
9
+ private readonly driver;
10
+ constructor(driver: BrowserDriver);
11
+ /** Single click at viewport pixel (x, y). */
12
+ click(x: number, y: number): Promise<Observation>;
13
+ /** Double click at viewport pixel (x, y). */
14
+ doubleClick(x: number, y: number): Promise<Observation>;
15
+ /** Type text into the currently focused element. */
16
+ type(text: string): Promise<Observation>;
17
+ /**
18
+ * Press keys in sequence. Each entry is a key name ('Enter', 'Tab') or a
19
+ * chord ('Control+a', 'Shift+ArrowLeft').
20
+ */
21
+ pressKeys(keys: string[]): Promise<Observation>;
22
+ /** Scroll the page by (dx, dy) viewport pixels. */
23
+ scroll(dx: number, dy: number): Promise<Observation>;
24
+ /** Wait ms milliseconds, then observe. */
25
+ wait(ms: number): Promise<Observation>;
26
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Action primitives over the driver page. All coordinates are plain viewport
3
+ * pixels — the pinned viewport + deviceScaleFactor: 1 means model coordinates
4
+ * map 1:1 (KTD2). Every action returns a post-action observation so callers
5
+ * can verify the result immediately.
6
+ */
7
+ export class Actions {
8
+ driver;
9
+ constructor(driver) {
10
+ this.driver = driver;
11
+ }
12
+ /** Single click at viewport pixel (x, y). */
13
+ async click(x, y) {
14
+ await this.driver.rawPage.mouse.click(x, y);
15
+ return this.driver.observe({ grid: true });
16
+ }
17
+ /** Double click at viewport pixel (x, y). */
18
+ async doubleClick(x, y) {
19
+ await this.driver.rawPage.mouse.dblclick(x, y);
20
+ return this.driver.observe({ grid: true });
21
+ }
22
+ /** Type text into the currently focused element. */
23
+ async type(text) {
24
+ await this.driver.rawPage.keyboard.type(text);
25
+ return this.driver.observe({ grid: true });
26
+ }
27
+ /**
28
+ * Press keys in sequence. Each entry is a key name ('Enter', 'Tab') or a
29
+ * chord ('Control+a', 'Shift+ArrowLeft').
30
+ */
31
+ async pressKeys(keys) {
32
+ for (const key of keys) {
33
+ await this.driver.rawPage.keyboard.press(key);
34
+ }
35
+ return this.driver.observe({ grid: true });
36
+ }
37
+ /** Scroll the page by (dx, dy) viewport pixels. */
38
+ async scroll(dx, dy) {
39
+ await this.driver.rawPage.mouse.wheel(dx, dy);
40
+ return this.driver.observe({ grid: true });
41
+ }
42
+ /** Wait ms milliseconds, then observe. */
43
+ async wait(ms) {
44
+ await this.driver.rawPage.waitForTimeout(ms);
45
+ return this.driver.observe({ grid: true });
46
+ }
47
+ }
@@ -0,0 +1,118 @@
1
+ import { BrowserDriver, Observation } from '../driver/browser.js';
2
+ import { Actions } from './actions.js';
3
+ import { Config, ProviderRules } from '../config.js';
4
+ import { CallCost, CallKind } from '../vision/cost.js';
5
+ import { Ledger } from '../vision/ledger.js';
6
+ import { JsonSchema, Message } from '../vision/openrouter.js';
7
+ import { FingerprintRecord, Point } from '../cache/fingerprint.js';
8
+ import { CachedAssert, FlowCache } from '../cache/store.js';
9
+ import { ErrorRecord } from '../journal/schema.js';
10
+ import { Logger } from '../log.js';
11
+ import { AssertionResult } from './prompts.js';
12
+ export interface VisionClient {
13
+ complete(opts: {
14
+ model: string;
15
+ messages: Message[];
16
+ schema?: JsonSchema;
17
+ escalationModels?: string[];
18
+ provider?: ProviderRules;
19
+ kind?: CallKind;
20
+ }): Promise<{
21
+ id: string;
22
+ content: string;
23
+ cost: CallCost;
24
+ model: string;
25
+ }>;
26
+ }
27
+ export interface TestDriverApi {
28
+ click(x: number, y: number): Promise<Observation>;
29
+ type(text: string): Promise<Observation>;
30
+ pressKeys(keys: string[]): Promise<Observation>;
31
+ scroll(dx: number, dy: number): Promise<Observation>;
32
+ wait(ms: number): Promise<Observation>;
33
+ }
34
+ export interface EngineOptions {
35
+ driver: BrowserDriver;
36
+ actions: Actions;
37
+ client: VisionClient;
38
+ ledger: Ledger;
39
+ config: Config;
40
+ /** Assertion verdicts persisted from a prior run of this flow. */
41
+ initialAsserts?: CachedAssert[];
42
+ /** Leveled logger; silent when absent. */
43
+ logger?: Logger;
44
+ }
45
+ export interface RecordOptions {
46
+ flowName?: string;
47
+ stepCap?: number;
48
+ }
49
+ export interface ReplayOptions {
50
+ flowName?: string;
51
+ }
52
+ export interface StepResult {
53
+ instruction: string;
54
+ action: string;
55
+ ok: boolean;
56
+ reason?: string;
57
+ healed?: boolean;
58
+ model?: string;
59
+ }
60
+ export interface RunResult {
61
+ ok: boolean;
62
+ reason?: string;
63
+ steps: StepResult[];
64
+ visionCalls: number;
65
+ }
66
+ export interface AssertResult extends AssertionResult {
67
+ cached: boolean;
68
+ }
69
+ export interface LocateResult {
70
+ ok: boolean;
71
+ reason: string | undefined;
72
+ healed: boolean;
73
+ point: Point | undefined;
74
+ fingerprint: FingerprintRecord | undefined;
75
+ model: string | undefined;
76
+ }
77
+ export declare class Engine {
78
+ private _opts;
79
+ private _visionCalls;
80
+ private _steps;
81
+ private _fingerprints;
82
+ private _assertCache;
83
+ private _errors;
84
+ /** Structured, non-fatal anomalies — journaled as evidence, never thrown. */
85
+ get errorRecords(): ErrorRecord[];
86
+ private _note;
87
+ constructor(_opts: EngineOptions);
88
+ /** Assertion verdicts collected/known this run — persist into the flow cache. */
89
+ get assertEntries(): CachedAssert[];
90
+ get visionCalls(): number;
91
+ record(instruction: string, tdApi?: TestDriverApi, options?: RecordOptions): Promise<RunResult>;
92
+ replay(flow: FlowCache, options?: ReplayOptions): Promise<RunResult>;
93
+ /**
94
+ * Resolve a single element for the `td.find()` DSL (R13). When `cached` is
95
+ * provided and still resolves locally, this costs zero vision calls (R2);
96
+ * otherwise it grounds (or heals) via the model and returns a fresh
97
+ * fingerprint (R4). The returned point is the viewport-pixel click target.
98
+ */
99
+ locate(instruction: string, cached?: FingerprintRecord): Promise<LocateResult>;
100
+ /**
101
+ * One locate attempt against a specific model: initial call plus the
102
+ * verify-then-correct loop. `modelOverride` is the escalation fallback —
103
+ * it keeps the action schema unless a specialist grounding model is in
104
+ * play (native "(x,y)" format).
105
+ */
106
+ private _locateWithModel;
107
+ assert(question: string): Promise<AssertResult>;
108
+ private _callModel;
109
+ private _parseAction;
110
+ private _parseAssertion;
111
+ private _resolveAction;
112
+ private _resolveNode;
113
+ private _executeAction;
114
+ private _buildFingerprint;
115
+ private _regionScreenshot;
116
+ private _result;
117
+ }
118
+ export declare function instructionMatchesNode(instruction: string, nodeSnippet: string): boolean;