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.
@@ -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
+ };