pi-harness-runtime 0.2.0 → 0.3.1
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 +133 -158
- package/harness/agent-handoff.ts +189 -0
- package/harness/blackboard.ts +291 -0
- package/harness/context-window-manager.ts +210 -0
- package/harness/e2e/playwright-runner.ts +252 -0
- package/harness/e2e/test-engine.ts +402 -0
- package/harness/job-state-machine.ts +363 -0
- package/harness/loop-runtime.ts +337 -0
- package/harness/master-planner.ts +327 -0
- package/harness/project-detector/detector.ts +328 -0
- package/harness/repair-engine.ts +340 -0
- package/harness/task-graph.ts +336 -0
- package/index.ts +294 -2
- package/package.json +6 -3
- package/packages/checkpoint/README.md +3 -0
- package/packages/checkpoint/src/checkpoint-manager.ts +38 -0
- package/packages/provider-router/src/provider-router.ts +42 -0
- package/packages/providers/README.md +3 -0
- package/packages/providers/adapters.ts +261 -0
- package/packages/quota-manager/README.md +3 -0
- package/packages/quota-manager/quota-manager.ts +328 -0
- package/packages/runtime/README.md +3 -0
- package/packages/scheduler/README.md +3 -0
- package/packages/scheduler/src/scheduler.ts +50 -0
- package/packages/shared-context/README.md +3 -0
- package/packages/shared-context/src/shared-context.ts +42 -0
- package/packages/tui/README.md +3 -0
- package/packages/types/src/runtime-types.ts +439 -0
- package/packages/worktree/README.md +3 -0
- package/packages/worktree/worktree.ts +293 -0
- package/skills/harness-runtime/SKILL.md +186 -72
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright Adapter — RFC-0004
|
|
3
|
+
*
|
|
4
|
+
* Uses a persistent browser profile to read provider console information
|
|
5
|
+
* when no official usage API exists. Initial target: MiniMax usage console.
|
|
6
|
+
*
|
|
7
|
+
* Also used as the E2E runner for testing workflows.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface PlaywrightRunnerConfig {
|
|
11
|
+
headless?: boolean;
|
|
12
|
+
slowMo?: number;
|
|
13
|
+
timeout?: number;
|
|
14
|
+
browser?: "chromium" | "firefox" | "webkit";
|
|
15
|
+
profileDir?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PlaywrightBrowser {
|
|
19
|
+
new (config?: PlaywrightRunnerConfig): PlaywrightBrowserInstance;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PlaywrightBrowserInstance {
|
|
23
|
+
page(): PlaywrightPage;
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PlaywrightPage {
|
|
28
|
+
goto(url: string): Promise<void>;
|
|
29
|
+
click(selector: string): Promise<void>;
|
|
30
|
+
fill(selector: string, value: string): Promise<void>;
|
|
31
|
+
waitForSelector(
|
|
32
|
+
selector: string,
|
|
33
|
+
options?: { timeout?: number },
|
|
34
|
+
): Promise<void>;
|
|
35
|
+
screenshot(options?: { path?: string }): Promise<void>;
|
|
36
|
+
evaluate<T>(fn: (...args: unknown[]) => T, ...args: unknown[]): Promise<T>;
|
|
37
|
+
content(): Promise<string>;
|
|
38
|
+
url(): Promise<string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface QuotaPageData {
|
|
42
|
+
provider: string;
|
|
43
|
+
h5UsedPct: number;
|
|
44
|
+
h5ResetsAt?: string;
|
|
45
|
+
weeklyUsedPct: number;
|
|
46
|
+
weeklyResetsAt?: string;
|
|
47
|
+
dailyUsedPct?: number;
|
|
48
|
+
dailyResetsAt?: string;
|
|
49
|
+
raw?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* MiniMax quota scraper using Playwright
|
|
54
|
+
*/
|
|
55
|
+
export class MiniMaxQuotaScraper {
|
|
56
|
+
private config: PlaywrightRunnerConfig;
|
|
57
|
+
|
|
58
|
+
constructor(config: PlaywrightRunnerConfig = {}) {
|
|
59
|
+
this.config = {
|
|
60
|
+
headless: true,
|
|
61
|
+
timeout: 30000,
|
|
62
|
+
browser: "chromium",
|
|
63
|
+
...config,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Scrape quota data from MiniMax console
|
|
69
|
+
*/
|
|
70
|
+
async scrape(): Promise<QuotaPageData> {
|
|
71
|
+
// In a real implementation, this would use playwright
|
|
72
|
+
// For now, return a placeholder
|
|
73
|
+
return {
|
|
74
|
+
provider: "minimax",
|
|
75
|
+
h5UsedPct: 0,
|
|
76
|
+
weeklyUsedPct: 0,
|
|
77
|
+
raw: "Playwright integration placeholder",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Wait for quota data to load
|
|
83
|
+
*/
|
|
84
|
+
async waitForQuotaLoad(
|
|
85
|
+
page: PlaywrightPage,
|
|
86
|
+
timeoutMs?: number,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const selectors = [
|
|
89
|
+
".quota-used",
|
|
90
|
+
".usage-percentage",
|
|
91
|
+
"[data-quota]",
|
|
92
|
+
".limit-bar",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const timeout = timeoutMs ?? this.config.timeout ?? 30000;
|
|
96
|
+
|
|
97
|
+
for (const selector of selectors) {
|
|
98
|
+
try {
|
|
99
|
+
await page.waitForSelector(selector, { timeout });
|
|
100
|
+
return;
|
|
101
|
+
} catch {
|
|
102
|
+
// Try next selector
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
throw new Error("Could not find quota element on page");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parse quota percentage from text
|
|
111
|
+
*/
|
|
112
|
+
parsePercentage(text: string): number {
|
|
113
|
+
const match = text.match(/(\d+(?:\.\d+)?)\s*%/);
|
|
114
|
+
return match ? parseFloat(match[1]) : 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse reset time from text
|
|
119
|
+
*/
|
|
120
|
+
parseResetTime(text: string): string | undefined {
|
|
121
|
+
// Examples: "Resets in 4 hr 56 min", "Resets in 2d 13h"
|
|
122
|
+
const match = text.match(/Resets?\s+in\s+(\d+d\s*)?(\d+h\s*)?(\d+m?)?/i);
|
|
123
|
+
if (!match) return undefined;
|
|
124
|
+
|
|
125
|
+
const parts = [];
|
|
126
|
+
if (match[1]) parts.push(match[1]);
|
|
127
|
+
if (match[2]) parts.push(match[2]);
|
|
128
|
+
if (match[3]) parts.push(match[3]);
|
|
129
|
+
|
|
130
|
+
return parts.join("").trim();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Playwright E2E Runner for testing workflows
|
|
136
|
+
*/
|
|
137
|
+
export class PlaywrightE2ERunner {
|
|
138
|
+
private config: PlaywrightRunnerConfig;
|
|
139
|
+
private browser: PlaywrightBrowserInstance | null = null;
|
|
140
|
+
private page: PlaywrightPage | null = null;
|
|
141
|
+
|
|
142
|
+
constructor(config: PlaywrightRunnerConfig = {}) {
|
|
143
|
+
this.config = {
|
|
144
|
+
headless: true,
|
|
145
|
+
slowMo: 0,
|
|
146
|
+
timeout: 30000,
|
|
147
|
+
browser: "chromium",
|
|
148
|
+
...config,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Start the browser
|
|
154
|
+
*/
|
|
155
|
+
async start(): Promise<void> {
|
|
156
|
+
// In a real implementation, this would launch Playwright
|
|
157
|
+
// this.browser = await chromium.launch({ headless: this.config.headless });
|
|
158
|
+
// this.page = await this.browser.newPage();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Stop the browser
|
|
163
|
+
*/
|
|
164
|
+
async stop(): Promise<void> {
|
|
165
|
+
if (this.page) {
|
|
166
|
+
// await this.page.close();
|
|
167
|
+
this.page = null;
|
|
168
|
+
}
|
|
169
|
+
if (this.browser) {
|
|
170
|
+
// await this.browser.close();
|
|
171
|
+
this.browser = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Navigate to a URL
|
|
177
|
+
*/
|
|
178
|
+
async navigate(url: string): Promise<void> {
|
|
179
|
+
if (!this.page) throw new Error("Browser not started");
|
|
180
|
+
await this.page.goto(url);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Click an element
|
|
185
|
+
*/
|
|
186
|
+
async click(selector: string): Promise<void> {
|
|
187
|
+
if (!this.page) throw new Error("Browser not started");
|
|
188
|
+
await this.page.waitForSelector(selector, { timeout: this.config.timeout });
|
|
189
|
+
await this.page.click(selector);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Fill an input
|
|
194
|
+
*/
|
|
195
|
+
async fill(selector: string, value: string): Promise<void> {
|
|
196
|
+
if (!this.page) throw new Error("Browser not started");
|
|
197
|
+
await this.page.waitForSelector(selector, { timeout: this.config.timeout });
|
|
198
|
+
await this.page.fill(selector, value);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Wait for selector
|
|
203
|
+
*/
|
|
204
|
+
async wait(selector: string, timeoutMs?: number): Promise<void> {
|
|
205
|
+
if (!this.page) throw new Error("Browser not started");
|
|
206
|
+
await this.page.waitForSelector(selector, {
|
|
207
|
+
timeout: timeoutMs ?? this.config.timeout,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Take a screenshot
|
|
213
|
+
*/
|
|
214
|
+
async screenshot(path: string): Promise<void> {
|
|
215
|
+
if (!this.page) throw new Error("Browser not started");
|
|
216
|
+
await this.page.screenshot({ path });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Evaluate JavaScript
|
|
221
|
+
*/
|
|
222
|
+
async evaluate<T>(fn: () => T): Promise<T> {
|
|
223
|
+
if (!this.page) throw new Error("Browser not started");
|
|
224
|
+
return this.page.evaluate(fn);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get current URL
|
|
229
|
+
*/
|
|
230
|
+
async getUrl(): Promise<string> {
|
|
231
|
+
if (!this.page) throw new Error("Browser not started");
|
|
232
|
+
return this.page.url();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Assert a condition
|
|
237
|
+
*/
|
|
238
|
+
async assert(condition: string, message?: string): Promise<boolean> {
|
|
239
|
+
if (!this.page) throw new Error("Browser not started");
|
|
240
|
+
|
|
241
|
+
const result = await this.page.evaluate((cond: unknown) => {
|
|
242
|
+
// eslint-disable-next-line no-eval
|
|
243
|
+
return eval(String(cond)) as boolean; // eslint-disable-line
|
|
244
|
+
}, condition);
|
|
245
|
+
|
|
246
|
+
if (!result && message) {
|
|
247
|
+
throw new Error(message);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E Test Engine — RFC-0013
|
|
3
|
+
*
|
|
4
|
+
* Browser-based end-to-end testing before work is marked ready for client.
|
|
5
|
+
*
|
|
6
|
+
* Artifact Layout:
|
|
7
|
+
* harness/e2e/
|
|
8
|
+
* scenarios/
|
|
9
|
+
* test-data/
|
|
10
|
+
* reports/
|
|
11
|
+
* artifacts/screenshots/
|
|
12
|
+
* artifacts/traces/
|
|
13
|
+
* artifacts/videos/
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
E2EScenario,
|
|
18
|
+
E2EStep,
|
|
19
|
+
E2EResult,
|
|
20
|
+
E2EReport,
|
|
21
|
+
} from "../../packages/types/src/runtime-types.ts";
|
|
22
|
+
import { writeJson, appendJsonl } from "../../cli.ts";
|
|
23
|
+
// @ts-expect-error - Bun has built-in Node.js types
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
export interface PlaywrightRunner {
|
|
27
|
+
navigate(url: string): Promise<void>;
|
|
28
|
+
click(selector: string): Promise<void>;
|
|
29
|
+
type(selector: string, text: string): Promise<void>;
|
|
30
|
+
wait(selector: string, timeout?: number): Promise<void>;
|
|
31
|
+
screenshot(path: string): Promise<void>;
|
|
32
|
+
assert(condition: string, message?: string): Promise<boolean>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface E2EConfig {
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
headless?: boolean;
|
|
38
|
+
screenshotOnFailure?: boolean;
|
|
39
|
+
videoOnFailure?: boolean;
|
|
40
|
+
traceOnFailure?: boolean;
|
|
41
|
+
timeout?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class E2ETestEngine {
|
|
45
|
+
private readonly rootDir: string;
|
|
46
|
+
private readonly config: E2EConfig;
|
|
47
|
+
private runner: PlaywrightRunner | null = null;
|
|
48
|
+
|
|
49
|
+
constructor(rootDir: string, config: E2EConfig) {
|
|
50
|
+
this.rootDir = rootDir;
|
|
51
|
+
this.config = {
|
|
52
|
+
headless: true,
|
|
53
|
+
screenshotOnFailure: true,
|
|
54
|
+
videoOnFailure: false,
|
|
55
|
+
traceOnFailure: false,
|
|
56
|
+
timeout: 30000,
|
|
57
|
+
...config,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Set the Playwright runner
|
|
63
|
+
*/
|
|
64
|
+
setRunner(runner: PlaywrightRunner): void {
|
|
65
|
+
this.runner = runner;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run a single E2E scenario
|
|
70
|
+
*/
|
|
71
|
+
async runScenario(
|
|
72
|
+
scenario: E2EScenario,
|
|
73
|
+
context?: Record<string, unknown>,
|
|
74
|
+
): Promise<E2EResult> {
|
|
75
|
+
if (!this.runner) {
|
|
76
|
+
return this.createErrorResult(scenario.id, "No runner configured");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const startTime = Date.now();
|
|
80
|
+
let stepsExecuted = 0;
|
|
81
|
+
let stepsPassed = 0;
|
|
82
|
+
let stepsFailed = 0;
|
|
83
|
+
let screenshotPath: string | undefined;
|
|
84
|
+
let failedStep: number | undefined;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
for (let i = 0; i < scenario.steps.length; i++) {
|
|
88
|
+
const step = scenario.steps[i];
|
|
89
|
+
stepsExecuted++;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const success = await this.executeStep(step, context);
|
|
93
|
+
if (success) {
|
|
94
|
+
stepsPassed++;
|
|
95
|
+
} else {
|
|
96
|
+
stepsFailed++;
|
|
97
|
+
failedStep = i;
|
|
98
|
+
if (this.config.screenshotOnFailure) {
|
|
99
|
+
screenshotPath = await this.captureScreenshot(scenario.id, i);
|
|
100
|
+
}
|
|
101
|
+
break; // Stop on first failure
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
stepsFailed++;
|
|
105
|
+
failedStep = i;
|
|
106
|
+
if (this.config.screenshotOnFailure) {
|
|
107
|
+
screenshotPath = await this.captureScreenshot(scenario.id, i);
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const duration = Date.now() - startTime;
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
scenarioId: scenario.id,
|
|
117
|
+
status: stepsFailed > 0 ? "failed" : "passed",
|
|
118
|
+
duration,
|
|
119
|
+
stepsExecuted,
|
|
120
|
+
stepsPassed,
|
|
121
|
+
stepsFailed,
|
|
122
|
+
screenshotPath,
|
|
123
|
+
executedAt: new Date().toISOString(),
|
|
124
|
+
failedStep,
|
|
125
|
+
};
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return this.createErrorResult(scenario.id, String(error));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Run all scenarios for a job
|
|
133
|
+
*/
|
|
134
|
+
async runAllScenarios(
|
|
135
|
+
jobId: string,
|
|
136
|
+
scenarios: E2EScenario[],
|
|
137
|
+
context?: Record<string, unknown>,
|
|
138
|
+
): Promise<E2EReport> {
|
|
139
|
+
const results: E2EResult[] = [];
|
|
140
|
+
let totalDuration = 0;
|
|
141
|
+
|
|
142
|
+
for (const scenario of scenarios) {
|
|
143
|
+
if (!scenario.required) {
|
|
144
|
+
// Skip non-required scenarios on failure of required ones
|
|
145
|
+
const result = await this.runScenario(scenario, context);
|
|
146
|
+
results.push(result);
|
|
147
|
+
totalDuration += result.duration;
|
|
148
|
+
} else {
|
|
149
|
+
const result = await this.runScenario(scenario, context);
|
|
150
|
+
results.push(result);
|
|
151
|
+
totalDuration += result.duration;
|
|
152
|
+
|
|
153
|
+
// Stop on first required failure
|
|
154
|
+
if (result.status === "failed") {
|
|
155
|
+
// Run remaining non-required scenarios
|
|
156
|
+
for (const nextScenario of scenarios.slice(
|
|
157
|
+
scenarios.indexOf(scenario) + 1,
|
|
158
|
+
)) {
|
|
159
|
+
if (!nextScenario.required) {
|
|
160
|
+
const nextResult = await this.runScenario(nextScenario, context);
|
|
161
|
+
results.push(nextResult);
|
|
162
|
+
totalDuration += nextResult.duration;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const report: E2EReport = {
|
|
171
|
+
jobId,
|
|
172
|
+
scenarios,
|
|
173
|
+
results,
|
|
174
|
+
summary: {
|
|
175
|
+
total: results.length,
|
|
176
|
+
passed: results.filter((r) => r.status === "passed").length,
|
|
177
|
+
failed: results.filter((r) => r.status === "failed").length,
|
|
178
|
+
skipped: results.filter((r) => r.status === "skipped").length,
|
|
179
|
+
duration: totalDuration,
|
|
180
|
+
},
|
|
181
|
+
createdAt: new Date().toISOString(),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Save report
|
|
185
|
+
this.saveReport(report);
|
|
186
|
+
|
|
187
|
+
return report;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Create a scenario from a natural language description
|
|
192
|
+
*/
|
|
193
|
+
createScenario(
|
|
194
|
+
id: string,
|
|
195
|
+
name: string,
|
|
196
|
+
description: string,
|
|
197
|
+
required: boolean = true,
|
|
198
|
+
): E2EScenario {
|
|
199
|
+
return {
|
|
200
|
+
id,
|
|
201
|
+
name,
|
|
202
|
+
description,
|
|
203
|
+
steps: [],
|
|
204
|
+
required,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Add a step to a scenario
|
|
210
|
+
*/
|
|
211
|
+
addStep(
|
|
212
|
+
scenario: E2EScenario,
|
|
213
|
+
action: E2EStep["action"],
|
|
214
|
+
options?: Partial<E2EStep>,
|
|
215
|
+
): void {
|
|
216
|
+
scenario.steps.push({
|
|
217
|
+
action,
|
|
218
|
+
selector: options?.selector,
|
|
219
|
+
value: options?.value,
|
|
220
|
+
timeout: options?.timeout,
|
|
221
|
+
assertCondition: options?.assertCondition,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Execute a single step
|
|
227
|
+
*/
|
|
228
|
+
private async executeStep(
|
|
229
|
+
step: E2EStep,
|
|
230
|
+
context?: Record<string, unknown>,
|
|
231
|
+
): Promise<boolean> {
|
|
232
|
+
if (!this.runner) return false;
|
|
233
|
+
|
|
234
|
+
const timeout = step.timeout ?? this.config.timeout ?? 30000;
|
|
235
|
+
|
|
236
|
+
switch (step.action) {
|
|
237
|
+
case "navigate":
|
|
238
|
+
await this.runner.navigate(
|
|
239
|
+
this.resolveValue(step.value ?? "", context),
|
|
240
|
+
);
|
|
241
|
+
return true;
|
|
242
|
+
|
|
243
|
+
case "click":
|
|
244
|
+
await this.runner.wait(step.selector!, timeout);
|
|
245
|
+
await this.runner.click(step.selector!);
|
|
246
|
+
return true;
|
|
247
|
+
|
|
248
|
+
case "type":
|
|
249
|
+
await this.runner.wait(step.selector!, timeout);
|
|
250
|
+
await this.runner.type(
|
|
251
|
+
step.selector!,
|
|
252
|
+
this.resolveValue(step.value ?? "", context),
|
|
253
|
+
);
|
|
254
|
+
return true;
|
|
255
|
+
|
|
256
|
+
case "wait":
|
|
257
|
+
await this.runner.wait(step.selector!, timeout);
|
|
258
|
+
return true;
|
|
259
|
+
|
|
260
|
+
case "screenshot": {
|
|
261
|
+
const path = this.resolveValue(step.value ?? "screenshot.png", context);
|
|
262
|
+
await this.runner.screenshot(path);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
case "assert":
|
|
267
|
+
return await this.runner.assert(
|
|
268
|
+
step.assertCondition ?? "true",
|
|
269
|
+
`Assertion failed: ${step.assertCondition}`,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
case "hover":
|
|
273
|
+
await this.runner.wait(step.selector!, timeout);
|
|
274
|
+
// await this.runner.hover(step.selector!);
|
|
275
|
+
return true;
|
|
276
|
+
|
|
277
|
+
case "select":
|
|
278
|
+
await this.runner.wait(step.selector!, timeout);
|
|
279
|
+
// await this.runner.select(step.selector!, step.value!);
|
|
280
|
+
return true;
|
|
281
|
+
|
|
282
|
+
case "upload":
|
|
283
|
+
await this.runner.wait(step.selector!, timeout);
|
|
284
|
+
// await this.runner.upload(step.selector!, step.value!);
|
|
285
|
+
return true;
|
|
286
|
+
|
|
287
|
+
default:
|
|
288
|
+
console.warn(`Unknown step action: ${step.action}`);
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Capture screenshot
|
|
295
|
+
*/
|
|
296
|
+
private async captureScreenshot(
|
|
297
|
+
scenarioId: string,
|
|
298
|
+
stepIndex: number,
|
|
299
|
+
): Promise<string> {
|
|
300
|
+
const path = join(
|
|
301
|
+
this.rootDir,
|
|
302
|
+
"harness",
|
|
303
|
+
"e2e",
|
|
304
|
+
"artifacts",
|
|
305
|
+
"screenshots",
|
|
306
|
+
`${scenarioId}-step-${stepIndex}.png`,
|
|
307
|
+
);
|
|
308
|
+
if (this.runner) {
|
|
309
|
+
await this.runner.screenshot(path);
|
|
310
|
+
}
|
|
311
|
+
return path;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Create error result
|
|
316
|
+
*/
|
|
317
|
+
private createErrorResult(scenarioId: string, error: string): E2EResult {
|
|
318
|
+
return {
|
|
319
|
+
scenarioId,
|
|
320
|
+
status: "error",
|
|
321
|
+
duration: 0,
|
|
322
|
+
stepsExecuted: 0,
|
|
323
|
+
stepsPassed: 0,
|
|
324
|
+
stepsFailed: 0,
|
|
325
|
+
errorMessage: error,
|
|
326
|
+
executedAt: new Date().toISOString(),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Save report to file
|
|
332
|
+
*/
|
|
333
|
+
private saveReport(report: E2EReport): void {
|
|
334
|
+
const reportPath = join(
|
|
335
|
+
this.rootDir,
|
|
336
|
+
"harness",
|
|
337
|
+
"e2e",
|
|
338
|
+
"reports",
|
|
339
|
+
`${report.jobId}-${Date.now()}.json`,
|
|
340
|
+
);
|
|
341
|
+
writeJson(reportPath, report);
|
|
342
|
+
|
|
343
|
+
// Also append to a log
|
|
344
|
+
const logPath = join(
|
|
345
|
+
this.rootDir,
|
|
346
|
+
"harness",
|
|
347
|
+
"e2e",
|
|
348
|
+
"reports",
|
|
349
|
+
`${report.jobId}.jsonl`,
|
|
350
|
+
);
|
|
351
|
+
appendJsonl(logPath, report);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Resolve variables in values
|
|
356
|
+
*/
|
|
357
|
+
private resolveValue(
|
|
358
|
+
value: string,
|
|
359
|
+
context?: Record<string, unknown>,
|
|
360
|
+
): string {
|
|
361
|
+
if (!context) return value;
|
|
362
|
+
let result = value;
|
|
363
|
+
for (const [key, val] of Object.entries(context)) {
|
|
364
|
+
result = result.replace(new RegExp(`{{${key}}}`, "g"), String(val));
|
|
365
|
+
}
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Load scenarios from directory
|
|
371
|
+
*/
|
|
372
|
+
loadScenarios(_scenariosDir: string): E2EScenario[] {
|
|
373
|
+
// In practice, this would read from the scenarios directory
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Default E2E steps for common workflows
|
|
380
|
+
*/
|
|
381
|
+
export const CommonSteps = {
|
|
382
|
+
login: (username: string, password: string): E2EStep[] => [
|
|
383
|
+
{ action: "navigate", value: "/login" },
|
|
384
|
+
{ action: "type", selector: "#username", value: username },
|
|
385
|
+
{ action: "type", selector: "#password", value: password },
|
|
386
|
+
{ action: "click", selector: 'button[type="submit"]' },
|
|
387
|
+
{ action: "wait", selector: ".dashboard", timeout: 10000 },
|
|
388
|
+
],
|
|
389
|
+
|
|
390
|
+
logout: (): E2EStep[] => [
|
|
391
|
+
{ action: "click", selector: ".user-menu" },
|
|
392
|
+
{ action: "click", selector: 'a[href="/logout"]' },
|
|
393
|
+
],
|
|
394
|
+
|
|
395
|
+
fillForm: (fields: Record<string, string>): E2EStep[] => {
|
|
396
|
+
const steps: E2EStep[] = [];
|
|
397
|
+
for (const [selector, value] of Object.entries(fields)) {
|
|
398
|
+
steps.push({ action: "type", selector, value });
|
|
399
|
+
}
|
|
400
|
+
return steps;
|
|
401
|
+
},
|
|
402
|
+
};
|