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,46 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ function escapeXml(value) {
4
+ return value
5
+ .replace(/&/g, '&')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;')
9
+ .replace(/'/g, '&apos;');
10
+ }
11
+ /** Render a JUnit XML report: one <testcase> per test with pass/fail + duration. */
12
+ export function renderJunitXml(suiteName, cases) {
13
+ const failures = cases.filter((c) => !c.ok).length;
14
+ const totalSeconds = cases.reduce((sum, c) => sum + c.durationMs, 0) / 1000;
15
+ const body = cases
16
+ .map((c) => {
17
+ const attrs = [
18
+ `name="${escapeXml(c.name)}"`,
19
+ `classname="${escapeXml(c.className ?? suiteName)}"`,
20
+ `time="${(c.durationMs / 1000).toFixed(3)}"`,
21
+ ].join(' ');
22
+ if (c.ok) {
23
+ return ` <testcase ${attrs}/>`;
24
+ }
25
+ const message = escapeXml(c.failureMessage ?? 'test failed');
26
+ return [
27
+ ` <testcase ${attrs}>`,
28
+ ` <failure message="${message}"/>`,
29
+ ` </testcase>`,
30
+ ].join('\n');
31
+ })
32
+ .join('\n');
33
+ return [
34
+ '<?xml version="1.0" encoding="UTF-8"?>',
35
+ `<testsuites tests="${cases.length}" failures="${failures}" time="${totalSeconds.toFixed(3)}">`,
36
+ ` <testsuite name="${escapeXml(suiteName)}" tests="${cases.length}" failures="${failures}" time="${totalSeconds.toFixed(3)}">`,
37
+ body,
38
+ ' </testsuite>',
39
+ '</testsuites>',
40
+ '',
41
+ ].join('\n');
42
+ }
43
+ export async function writeJunitXml(path, suiteName, cases) {
44
+ await mkdir(dirname(path), { recursive: true });
45
+ await writeFile(path, renderJunitXml(suiteName, cases), 'utf8');
46
+ }
@@ -0,0 +1,51 @@
1
+ import type { HealEvent, TdAssertRecord, TdStepRecord } from '../api.js';
2
+ import type { CallCost } from '../vision/cost.js';
3
+ /**
4
+ * JSON run report consumed by the GitHub Action (R10): per-test verdicts,
5
+ * heal events, vision-call counts, ledger totals, and artifact paths.
6
+ */
7
+ export interface TestReport {
8
+ name: string;
9
+ file: string;
10
+ ok: boolean;
11
+ durationMs: number;
12
+ failureMessage: string | undefined;
13
+ steps: TdStepRecord[];
14
+ asserts: TdAssertRecord[];
15
+ healEvents: HealEvent[];
16
+ visionCalls: number;
17
+ visionCostUsd: number;
18
+ sandboxSeconds: number;
19
+ budgetExceeded: boolean;
20
+ /** Per-call cost rows for model-level attribution. */
21
+ calls: CallCost[];
22
+ videoPath: string | undefined;
23
+ /** Agent Zero's autonomous second opinion on a failure (heal: 'a0'). */
24
+ a0Diagnosis?: string;
25
+ }
26
+ export interface RunTotals {
27
+ tests: number;
28
+ passed: number;
29
+ failed: number;
30
+ visionCalls: number;
31
+ visionCostUsd: number;
32
+ sandboxSeconds: number;
33
+ budgetExceeded: boolean;
34
+ /** OpenRouter spend grouped by model id. */
35
+ costByModel: Record<string, number>;
36
+ /** OpenRouter call count grouped by model id. */
37
+ callsByModel: Record<string, number>;
38
+ }
39
+ export interface RunReport {
40
+ tool: 'argus-reviewer';
41
+ startedAt: string;
42
+ durationMs: number;
43
+ ok: boolean;
44
+ totals: RunTotals;
45
+ tests: TestReport[];
46
+ artifacts: {
47
+ videos: string[];
48
+ };
49
+ }
50
+ export declare function buildRunReport(tests: TestReport[], startedAt: Date, durationMs: number): RunReport;
51
+ export declare function writeRunReport(path: string, report: RunReport): Promise<void>;
@@ -0,0 +1,36 @@
1
+ import { writeAtomicJson } from '../fsutil.js';
2
+ export function buildRunReport(tests, startedAt, durationMs) {
3
+ const failed = tests.filter((t) => !t.ok).length;
4
+ const callsByModel = {};
5
+ const costByModel = {};
6
+ for (const t of tests) {
7
+ for (const c of t.calls) {
8
+ callsByModel[c.model] = (callsByModel[c.model] ?? 0) + 1;
9
+ costByModel[c.model] = (costByModel[c.model] ?? 0) + c.costUsd;
10
+ }
11
+ }
12
+ return {
13
+ tool: 'argus-reviewer',
14
+ startedAt: startedAt.toISOString(),
15
+ durationMs,
16
+ ok: failed === 0,
17
+ totals: {
18
+ tests: tests.length,
19
+ passed: tests.length - failed,
20
+ failed,
21
+ visionCalls: tests.reduce((sum, t) => sum + t.visionCalls, 0),
22
+ visionCostUsd: tests.reduce((sum, t) => sum + t.visionCostUsd, 0),
23
+ sandboxSeconds: tests.reduce((sum, t) => sum + t.sandboxSeconds, 0),
24
+ budgetExceeded: tests.some((t) => t.budgetExceeded),
25
+ callsByModel,
26
+ costByModel,
27
+ },
28
+ tests,
29
+ artifacts: {
30
+ videos: tests.map((t) => t.videoPath).filter((p) => p !== undefined),
31
+ },
32
+ };
33
+ }
34
+ export async function writeRunReport(path, report) {
35
+ await writeAtomicJson(path, report);
36
+ }
@@ -0,0 +1,36 @@
1
+ export type CallKind = 'ground' | 'heal' | 'assert' | 'code';
2
+ export interface CallCost {
3
+ model: string;
4
+ provider: string;
5
+ tokens: number;
6
+ costUsd: number;
7
+ kind: CallKind;
8
+ }
9
+ export interface ChoiceMessage {
10
+ content?: unknown;
11
+ }
12
+ export interface Choice {
13
+ message?: ChoiceMessage;
14
+ }
15
+ export interface Usage {
16
+ total_tokens: number;
17
+ cost: number;
18
+ cost_details: {
19
+ upstream_inference_cost: number;
20
+ };
21
+ }
22
+ export type ProviderValue = string | {
23
+ name?: string;
24
+ } | undefined;
25
+ export interface OpenRouterResponse {
26
+ id: string;
27
+ model: string;
28
+ provider?: ProviderValue;
29
+ choices: Choice[];
30
+ usage: Usage;
31
+ }
32
+ export declare function makeCallCost(response: OpenRouterResponse, kind: CallKind): CallCost;
33
+ export declare function extractUsageCost(response: OpenRouterResponse): {
34
+ costUsd: number;
35
+ upstreamCostUsd: number;
36
+ };
@@ -0,0 +1,16 @@
1
+ export function makeCallCost(response, kind) {
2
+ const providerName = typeof response.provider === 'string' ? response.provider : response.provider?.name ?? 'unknown';
3
+ return {
4
+ model: response.model,
5
+ provider: providerName,
6
+ tokens: response.usage.total_tokens,
7
+ costUsd: response.usage.cost,
8
+ kind,
9
+ };
10
+ }
11
+ export function extractUsageCost(response) {
12
+ return {
13
+ costUsd: response.usage.cost,
14
+ upstreamCostUsd: response.usage.cost_details.upstream_inference_cost,
15
+ };
16
+ }
@@ -0,0 +1,29 @@
1
+ import { CallCost } from './cost.js';
2
+ export interface LedgerState {
3
+ visionCostUsd: number;
4
+ sandboxSeconds: number;
5
+ budgetExceeded: boolean;
6
+ replayOnly: boolean;
7
+ calls: CallCost[];
8
+ }
9
+ export declare class Ledger {
10
+ private _budgetUsd;
11
+ private _visionCostUsd;
12
+ private _sandboxStartedAt;
13
+ private _sandboxSeconds;
14
+ private _calls;
15
+ private _budgetExceeded;
16
+ private _replayOnly;
17
+ constructor(_budgetUsd: number | undefined);
18
+ get state(): LedgerState;
19
+ get budgetExceeded(): boolean;
20
+ get replayOnly(): boolean;
21
+ get visionCostUsd(): number;
22
+ get sandboxSeconds(): number;
23
+ get calls(): CallCost[];
24
+ startSandbox(): void;
25
+ stopSandbox(): void;
26
+ recordCall(cost: CallCost): void;
27
+ canSpend(estimatedUsd: number): boolean;
28
+ flagBudgetExceeded(): void;
29
+ }
@@ -0,0 +1,65 @@
1
+ export class Ledger {
2
+ _budgetUsd;
3
+ _visionCostUsd = 0;
4
+ _sandboxStartedAt;
5
+ _sandboxSeconds = 0;
6
+ _calls = [];
7
+ _budgetExceeded = false;
8
+ _replayOnly = false;
9
+ constructor(_budgetUsd) {
10
+ this._budgetUsd = _budgetUsd;
11
+ }
12
+ get state() {
13
+ return {
14
+ visionCostUsd: this._visionCostUsd,
15
+ sandboxSeconds: this._sandboxSeconds,
16
+ budgetExceeded: this._budgetExceeded,
17
+ replayOnly: this._replayOnly,
18
+ calls: [...this._calls],
19
+ };
20
+ }
21
+ get budgetExceeded() {
22
+ return this._budgetExceeded;
23
+ }
24
+ get replayOnly() {
25
+ return this._replayOnly;
26
+ }
27
+ get visionCostUsd() {
28
+ return this._visionCostUsd;
29
+ }
30
+ get sandboxSeconds() {
31
+ return this._sandboxSeconds;
32
+ }
33
+ get calls() {
34
+ return [...this._calls];
35
+ }
36
+ startSandbox() {
37
+ this._sandboxStartedAt = Date.now();
38
+ }
39
+ stopSandbox() {
40
+ if (this._sandboxStartedAt === undefined)
41
+ return;
42
+ this._sandboxSeconds += (Date.now() - this._sandboxStartedAt) / 1000;
43
+ this._sandboxStartedAt = undefined;
44
+ }
45
+ recordCall(cost) {
46
+ this._calls.push(cost);
47
+ this._visionCostUsd += cost.costUsd;
48
+ }
49
+ canSpend(estimatedUsd) {
50
+ if (this._budgetUsd === undefined)
51
+ return true;
52
+ if (this._budgetExceeded)
53
+ return false;
54
+ const projected = this._visionCostUsd + estimatedUsd;
55
+ if (projected <= this._budgetUsd)
56
+ return true;
57
+ this._budgetExceeded = true;
58
+ this._replayOnly = true;
59
+ return false;
60
+ }
61
+ flagBudgetExceeded() {
62
+ this._budgetExceeded = true;
63
+ this._replayOnly = true;
64
+ }
65
+ }
@@ -0,0 +1,70 @@
1
+ import { ProviderRules } from '../config.js';
2
+ import { CallCost, CallKind } from './cost.js';
3
+ export interface TextContentPart {
4
+ type: 'text';
5
+ text: string;
6
+ }
7
+ export interface ImageContentPart {
8
+ type: 'image';
9
+ source: string;
10
+ }
11
+ export type ContentPart = TextContentPart | ImageContentPart;
12
+ export interface Message {
13
+ role: 'user' | 'system' | 'assistant';
14
+ content: ContentPart[];
15
+ }
16
+ export interface JsonSchema {
17
+ name: string;
18
+ schema: Record<string, unknown>;
19
+ strict?: boolean;
20
+ }
21
+ export interface OpenRouterClientOptions {
22
+ apiKey: string;
23
+ fetch?: typeof fetch;
24
+ /**
25
+ * Extra metadata sent on every request. `trace` is merged into the request
26
+ * body `trace` field for cost attribution. `headers` are merged into the
27
+ * request headers (e.g. HTTP-Referer, X-Title).
28
+ */
29
+ trace?: Record<string, string>;
30
+ headers?: Record<string, string>;
31
+ /**
32
+ * Called once for every successful OpenRouter request with the resolved
33
+ * model, cost, and token usage. Useful for per-call logging.
34
+ */
35
+ onCall?: (call: {
36
+ id: string;
37
+ model: string;
38
+ kind: CallKind;
39
+ costUsd: number;
40
+ tokens: number;
41
+ trace?: Record<string, string>;
42
+ }) => void;
43
+ }
44
+ export declare class OpenRouterClient {
45
+ private _apiKey;
46
+ private _fetch;
47
+ private _trace;
48
+ private _headers;
49
+ private _onCall;
50
+ constructor(opts: OpenRouterClientOptions);
51
+ complete(opts: {
52
+ model: string;
53
+ messages: Message[];
54
+ schema?: JsonSchema;
55
+ escalationModels?: string[];
56
+ provider?: ProviderRules;
57
+ kind?: CallKind;
58
+ }): Promise<{
59
+ id: string;
60
+ content: string;
61
+ cost: CallCost;
62
+ model: string;
63
+ }>;
64
+ reconcile(id: string): Promise<{
65
+ costUsd: number;
66
+ }>;
67
+ private _tryComplete;
68
+ private _toApiMessages;
69
+ private _extractContent;
70
+ }
@@ -0,0 +1,134 @@
1
+ import { debug } from '../debug.js';
2
+ import { makeCallCost } from './cost.js';
3
+ export class OpenRouterClient {
4
+ _apiKey;
5
+ _fetch;
6
+ _trace;
7
+ _headers;
8
+ _onCall;
9
+ constructor(opts) {
10
+ if (!opts.apiKey) {
11
+ throw new Error('OPENROUTER_API_KEY is required: pass a non-empty apiKey at construction');
12
+ }
13
+ this._apiKey = opts.apiKey;
14
+ this._fetch = opts.fetch ?? globalThis.fetch;
15
+ this._trace = opts.trace;
16
+ this._headers = opts.headers;
17
+ this._onCall = opts.onCall;
18
+ }
19
+ async complete(opts) {
20
+ const candidates = [opts.model, ...(opts.escalationModels ?? [])];
21
+ const errors = [];
22
+ const kind = opts.kind ?? 'ground';
23
+ debug('openrouter', `kind=${kind} candidates=[${candidates.join(', ')}] messages=${opts.messages.length}`);
24
+ for (let i = 0; i < candidates.length; i++) {
25
+ const model = candidates[i];
26
+ if (model === undefined)
27
+ continue;
28
+ const remaining = candidates.slice(i + 1);
29
+ try {
30
+ const response = await this._tryComplete({
31
+ model,
32
+ messages: opts.messages,
33
+ ...(opts.schema !== undefined ? { schema: opts.schema } : {}),
34
+ ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
35
+ models: remaining,
36
+ kind,
37
+ });
38
+ const cost = makeCallCost(response, kind);
39
+ debug('openrouter', `model=${response.model} tokens=${cost.tokens} costUsd=${cost.costUsd.toFixed(6)} content_len=${this._extractContent(response).length}`);
40
+ this._onCall?.({
41
+ id: response.id,
42
+ model: response.model,
43
+ kind,
44
+ costUsd: cost.costUsd,
45
+ tokens: cost.tokens,
46
+ ...(this._trace ? { trace: this._trace } : {}),
47
+ });
48
+ return { id: response.id, content: this._extractContent(response), cost, model: response.model };
49
+ }
50
+ catch (e) {
51
+ const err = e;
52
+ debug('openrouter', `candidate ${model} failed: ${err.message}`);
53
+ errors.push(err);
54
+ }
55
+ }
56
+ throw new Error(`OpenRouter completion failed for all candidate models: ${errors.map((e) => e.message).join('; ')}`);
57
+ }
58
+ async reconcile(id) {
59
+ const res = await this._fetch(`https://openrouter.ai/api/v1/generation?id=${encodeURIComponent(id)}`, {
60
+ method: 'GET',
61
+ headers: { Authorization: `Bearer ${this._apiKey}` },
62
+ });
63
+ if (!res.ok) {
64
+ throw new Error(`OpenRouter generation reconcile failed: ${res.status} ${res.statusText}`);
65
+ }
66
+ const data = (await res.json());
67
+ const total = data.data?.total_cost ?? data.total_cost ?? 0;
68
+ return { costUsd: total };
69
+ }
70
+ async _tryComplete(req) {
71
+ const body = {
72
+ model: req.model,
73
+ messages: this._toApiMessages(req.messages),
74
+ };
75
+ if (req.provider) {
76
+ body.provider = req.provider;
77
+ }
78
+ if (req.models && req.models.length > 0) {
79
+ body.models = req.models;
80
+ }
81
+ if (req.schema) {
82
+ body.response_format = {
83
+ type: 'json_schema',
84
+ json_schema: {
85
+ name: req.schema.name,
86
+ schema: req.schema.schema,
87
+ strict: req.schema.strict ?? true,
88
+ },
89
+ };
90
+ }
91
+ if (this._trace) {
92
+ body.trace = this._trace;
93
+ }
94
+ const headers = {
95
+ Authorization: `Bearer ${this._apiKey}`,
96
+ 'Content-Type': 'application/json',
97
+ 'X-Title': 'argus-reviewer',
98
+ 'X-OpenRouter-Metadata': 'enabled',
99
+ ...(this._headers ?? {}),
100
+ };
101
+ const res = await this._fetch('https://openrouter.ai/api/v1/chat/completions', {
102
+ method: 'POST',
103
+ headers,
104
+ body: JSON.stringify(body),
105
+ });
106
+ if (!res.ok) {
107
+ throw new Error(`OpenRouter request failed: ${res.status} ${res.statusText}`);
108
+ }
109
+ return (await res.json());
110
+ }
111
+ _toApiMessages(messages) {
112
+ return messages.map((m) => ({
113
+ role: m.role,
114
+ content: m.content
115
+ .map((part) => {
116
+ if (part.type === 'text') {
117
+ return { type: 'text', text: part.text };
118
+ }
119
+ return {
120
+ type: 'image_url',
121
+ image_url: { url: `data:image/jpeg;base64,${part.source}` },
122
+ };
123
+ })
124
+ .sort((a) => (a.type === 'text' ? -1 : 1)),
125
+ }));
126
+ }
127
+ _extractContent(response) {
128
+ const first = response.choices[0];
129
+ const content = first?.message?.content;
130
+ if (typeof content === 'string')
131
+ return content;
132
+ return '';
133
+ }
134
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "argus-reviewer-e2e",
3
+ "version": "0.1.2",
4
+ "description": "argus-reviewer — open-source vision-model E2E testing harness. Record, replay, heal, and assert with any OpenRouter model under a hard budget cap.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Luke Kimball",
8
+ "homepage": "https://github.com/duketopceo/Argus#readme",
9
+ "bugs": "https://github.com/duketopceo/Argus/issues",
10
+ "keywords": [
11
+ "e2e-testing",
12
+ "vision-model",
13
+ "playwright",
14
+ "code-review",
15
+ "openrouter",
16
+ "github-actions",
17
+ "ai-testing",
18
+ "byok"
19
+ ],
20
+ "bin": {
21
+ "argus-reviewer": "dist/cli.js"
22
+ },
23
+ "exports": {
24
+ ".": "./dist/api.js",
25
+ "./api": "./dist/api.js",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "action"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20.19.0"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.build.json",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "lint": "eslint .",
40
+ "format": "prettier --write .",
41
+ "typecheck": "tsc --noEmit",
42
+ "prepare": "npm run build && chmod +x dist/cli.js",
43
+ "eval": "node evals/run.mjs",
44
+ "watch": "node scripts/watch.mjs",
45
+ "app": "electron electron"
46
+ },
47
+ "devDependencies": {
48
+ "@eslint/js": "^9.0.0",
49
+ "@types/node": "^20.0.0",
50
+ "electron": "^44.2.0",
51
+ "eslint": "^9.0.0",
52
+ "globals": "^15.0.0",
53
+ "prettier": "^3.0.0",
54
+ "typescript-eslint": "^8.0.0",
55
+ "vitest": "^2.0.0"
56
+ },
57
+ "dependencies": {
58
+ "playwright": "1.63.0",
59
+ "typescript": "^5.5.0"
60
+ },
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/duketopceo/Argus.git"
64
+ }
65
+ }