pi-harness-runtime 0.3.2-beta.2 → 0.4.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/CHANGELOG.md +18 -4
- package/README.md +20 -0
- package/harness/auto-compact.ts +326 -0
- package/harness/e2e/index.ts +44 -0
- package/harness/e2e/playwright-runner.ts +92 -53
- package/harness/e2e/test-engine.ts +9 -13
- package/harness/index.ts +105 -0
- package/harness/notification-events.ts +299 -0
- package/harness/output-limit-handler.ts +316 -0
- package/harness/partial-recovery.ts +407 -0
- package/package.json +5 -2
- package/packages/auth/src/minimax-browser-auth.ts +830 -180
- package/packages/auth/src/run-minimax-auth.ts +55 -7
- package/packages/notification/adapters/email-adapter.ts +126 -0
- package/packages/notification/adapters/ntfy-adapter.ts +100 -0
- package/packages/notification/adapters/telegram-adapter.ts +107 -0
- package/packages/notification/adapters/webhook-adapter.ts +75 -0
- package/packages/notification/base-adapter.ts +75 -0
- package/packages/notification/index.ts +51 -0
- package/packages/notification/notification-center.ts +286 -0
- package/packages/notification/types.ts +89 -0
- package/packages/providers/adapters.ts +102 -1
- package/packages/types/src/runtime-types.ts +3 -1
- package/skills/harness-runtime/SKILL.md +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
All notable changes to
|
|
3
|
+
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
## [0.4.0](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.3.1...v0.4.0) (2026-07-05)
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **auth:** Add curator-mode MiniMax browser authentication ([4914517](https://github.com/ManotLuijiu/pi-harness-runtime/commit/49145171d0205e78454472f5f4e540ff7c70a711))
|
|
11
|
+
* **auth:** Add MiniMax browser authentication prototype ([75e294e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/75e294e31f582521a48b66d1ce05e1cc3f581750))
|
|
12
|
+
* **auth:** Add persistent browser profile auth for MiniMax ([f725d2b](https://github.com/ManotLuijiu/pi-harness-runtime/commit/f725d2b89aa61d6e1cd5de0458b4d2c80efb6e7a))
|
|
13
|
+
* Fixed Chrome ([98becb8](https://github.com/ManotLuijiu/pi-harness-runtime/commit/98becb8bed6b94612db648d4bdd58f8a45d23040))
|
|
14
|
+
* implement RFC-0019 through RFC-0022 ([4468dcb](https://github.com/ManotLuijiu/pi-harness-runtime/commit/4468dcb1cfcb320f8eeab50c8ef590de74de979a))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* **auth:** Don't re-navigate after login detection ([133e6d5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/133e6d5ac852cc3048881eb4e28700c3aceca792))
|
|
20
|
+
* **auth:** Poll for content AND URL, log URL changes for debugging ([4a197fe](https://github.com/ManotLuijiu/pi-harness-runtime/commit/4a197febec24efe85568c3ce35286f45602f5e23))
|
|
21
|
+
* **auth:** Poll URL instead of stdin for login detection ([04ced9a](https://github.com/ManotLuijiu/pi-harness-runtime/commit/04ced9a76753e6b6d4b8e43c97fe19830ab08af1))
|
|
22
|
+
* **skill:** Add description field to harness-runtime SKILL.md ([d06d37b](https://github.com/ManotLuijiu/pi-harness-runtime/commit/d06d37becfd92e17ffe27dfbeac04da61f0d89df))
|
|
9
23
|
|
|
10
24
|
## [0.2.0] - 2026-06-29
|
|
11
25
|
|
package/README.md
CHANGED
|
@@ -178,3 +178,23 @@ MIT © 2026 MooCoding
|
|
|
178
178
|
- [pi-coding-agent](https://github.com/earendil-works/pi-coding-agent) — Underlying pi agent
|
|
179
179
|
- [context-mode](https://github.com/MiniMax-AI/context-mode) — Context window tracking
|
|
180
180
|
- [pi-web-access](https://github.com/nicobailon/pi-web-access) — Web search for pi
|
|
181
|
+
|
|
182
|
+
Day 6 adds runtime recovery and remote-observability architecture.
|
|
183
|
+
|
|
184
|
+
## RFCs
|
|
185
|
+
|
|
186
|
+
- RFC-0019 Auto Compact and Continue
|
|
187
|
+
- RFC-0020 Output Token Limit Handler
|
|
188
|
+
- RFC-0021 Partial Response Recovery
|
|
189
|
+
- RFC-0022 Notification Center
|
|
190
|
+
- RFC-0023 Local Browser Agent
|
|
191
|
+
|
|
192
|
+
## Why this matters
|
|
193
|
+
|
|
194
|
+
These RFCs address real runtime failures:
|
|
195
|
+
|
|
196
|
+
- MiniMax auto-compacts but does not automatically resume.
|
|
197
|
+
- A model may stop because max output token limit is reached.
|
|
198
|
+
- Partial responses must be preserved and continued.
|
|
199
|
+
- The human needs mobile/tablet notifications.
|
|
200
|
+
- Headless servers cannot open interactive login windows for MiniMax usage console.
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto Compact and Continue — RFC-0019
|
|
3
|
+
*
|
|
4
|
+
* Automatically resume work after model/session compaction without requiring
|
|
5
|
+
* human intervention. Keeps the human out of the message-bus role.
|
|
6
|
+
*
|
|
7
|
+
* Artifact Layout:
|
|
8
|
+
* harness/context/
|
|
9
|
+
* compaction_events.jsonl
|
|
10
|
+
* latest_compaction_summary.md
|
|
11
|
+
* continue_prompt.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
appendFileSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
|
|
24
|
+
export interface CompactionEvent {
|
|
25
|
+
timestamp: string;
|
|
26
|
+
jobId: string;
|
|
27
|
+
taskId: string;
|
|
28
|
+
compactedFromTokens: number;
|
|
29
|
+
reason: string;
|
|
30
|
+
errorMessage?: string;
|
|
31
|
+
partialOutput?: string;
|
|
32
|
+
continuePrompt?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CompactionConfig {
|
|
36
|
+
jobId: string;
|
|
37
|
+
rootDir?: string;
|
|
38
|
+
maxContinueAttempts?: number;
|
|
39
|
+
continuationBackoffMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ContinuePrompt {
|
|
43
|
+
taskId: string;
|
|
44
|
+
originalRequirement: string;
|
|
45
|
+
whatWasCompleted: string;
|
|
46
|
+
whatNeedsToBeDone: string;
|
|
47
|
+
partialFiles: string[];
|
|
48
|
+
nextSteps: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const COMPACTION_PATTERNS = [
|
|
52
|
+
/\[compaction\]/i,
|
|
53
|
+
/compacted from \d+[,.]?\d* tokens/i,
|
|
54
|
+
/model stopped because it reached the maximum output token limit/i,
|
|
55
|
+
/error.*output.*token.*limit/i,
|
|
56
|
+
/context.*truncated/i,
|
|
57
|
+
/session.*compact/i,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const CONTINUE_MARKERS = [
|
|
61
|
+
/would you like me to continue/i,
|
|
62
|
+
/should i continue/i,
|
|
63
|
+
/type.*continue/i,
|
|
64
|
+
/\[continue\]/i,
|
|
65
|
+
/waiting for your response/i,
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export class AutoCompactEngine {
|
|
69
|
+
private readonly rootDir: string;
|
|
70
|
+
private readonly jobId: string;
|
|
71
|
+
private readonly maxAttempts: number;
|
|
72
|
+
private readonly backoffMs: number;
|
|
73
|
+
private continueAttempts = 0;
|
|
74
|
+
|
|
75
|
+
constructor(config: CompactionConfig) {
|
|
76
|
+
this.rootDir =
|
|
77
|
+
config.rootDir ??
|
|
78
|
+
join(homedir(), ".pi", "harness", config.jobId, "context");
|
|
79
|
+
this.jobId = config.jobId;
|
|
80
|
+
this.maxAttempts = config.maxContinueAttempts ?? 5;
|
|
81
|
+
this.backoffMs = config.continuationBackoffMs ?? 1000;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Detect if output contains compaction markers
|
|
86
|
+
*/
|
|
87
|
+
detectCompaction(output: string): boolean {
|
|
88
|
+
return COMPACTION_PATTERNS.some((pattern) => pattern.test(output));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Detect if output is waiting for continue signal
|
|
93
|
+
*/
|
|
94
|
+
isWaitingForContinue(output: string): boolean {
|
|
95
|
+
return CONTINUE_MARKERS.some((pattern) => pattern.test(output));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse compaction details from output
|
|
100
|
+
*/
|
|
101
|
+
parseCompactionEvent(output: string, taskId: string): CompactionEvent | null {
|
|
102
|
+
const lines = output.split("\n");
|
|
103
|
+
let compactedFromTokens = 0;
|
|
104
|
+
let reason = "unknown";
|
|
105
|
+
let errorMessage: string | undefined;
|
|
106
|
+
|
|
107
|
+
// Extract compacted token count
|
|
108
|
+
const tokenMatch = output.match(
|
|
109
|
+
/compacted from ([,\d]+(?:\.\d+)?)\s*tokens/i,
|
|
110
|
+
);
|
|
111
|
+
if (tokenMatch) {
|
|
112
|
+
compactedFromTokens = parseInt(tokenMatch[1].replace(/,/g, ""), 10);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Extract reason
|
|
116
|
+
if (/output.*token.*limit/i.test(output)) {
|
|
117
|
+
reason = "output_token_limit";
|
|
118
|
+
} else if (/context.*truncated/i.test(output)) {
|
|
119
|
+
reason = "context_truncated";
|
|
120
|
+
} else if (/session.*compact/i.test(output)) {
|
|
121
|
+
reason = "session_compact";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Extract error message
|
|
125
|
+
const errorMatch = output.match(/error[:\s]+(.+)/i);
|
|
126
|
+
if (errorMatch) {
|
|
127
|
+
errorMessage = errorMatch[1].trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
timestamp: new Date().toISOString(),
|
|
132
|
+
jobId: this.jobId,
|
|
133
|
+
taskId,
|
|
134
|
+
compactedFromTokens,
|
|
135
|
+
reason,
|
|
136
|
+
errorMessage,
|
|
137
|
+
partialOutput: output,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Save compaction artifact
|
|
143
|
+
*/
|
|
144
|
+
saveCompactionArtifact(event: CompactionEvent): void {
|
|
145
|
+
this.ensureDir();
|
|
146
|
+
|
|
147
|
+
// Append to events log
|
|
148
|
+
const eventsPath = join(this.rootDir, "compaction_events.jsonl");
|
|
149
|
+
appendFileSync(eventsPath, JSON.stringify(event) + "\n", "utf-8");
|
|
150
|
+
|
|
151
|
+
// Write latest summary
|
|
152
|
+
const summaryPath = join(this.rootDir, "latest_compaction_summary.md");
|
|
153
|
+
const summary = this.generateSummary(event);
|
|
154
|
+
writeFileSync(summaryPath, summary, "utf-8");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Generate continue prompt
|
|
159
|
+
*/
|
|
160
|
+
generateContinuePrompt(
|
|
161
|
+
taskId: string,
|
|
162
|
+
completedWork: string,
|
|
163
|
+
remainingWork: string,
|
|
164
|
+
): ContinuePrompt {
|
|
165
|
+
const prompt: ContinuePrompt = {
|
|
166
|
+
taskId,
|
|
167
|
+
originalRequirement: this.getRequirement(),
|
|
168
|
+
whatWasCompleted: completedWork,
|
|
169
|
+
whatNeedsToBeDone: remainingWork,
|
|
170
|
+
partialFiles: this.findPartialFiles(),
|
|
171
|
+
nextSteps: remainingWork,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const promptPath = join(this.rootDir, "continue_prompt.md");
|
|
175
|
+
const promptContent = this.formatContinuePrompt(prompt);
|
|
176
|
+
writeFileSync(promptPath, promptContent, "utf-8");
|
|
177
|
+
|
|
178
|
+
return prompt;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check if we should continue (respects max attempts)
|
|
183
|
+
*/
|
|
184
|
+
shouldContinue(): boolean {
|
|
185
|
+
this.continueAttempts++;
|
|
186
|
+
return this.continueAttempts <= this.maxAttempts;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get current continue attempt count
|
|
191
|
+
*/
|
|
192
|
+
getContinueAttempts(): number {
|
|
193
|
+
return this.continueAttempts;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Reset continue attempts
|
|
198
|
+
*/
|
|
199
|
+
resetAttempts(): void {
|
|
200
|
+
this.continueAttempts = 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build the continue message for the next turn
|
|
205
|
+
*/
|
|
206
|
+
buildContinueMessage(): string {
|
|
207
|
+
const promptPath = join(this.rootDir, "continue_prompt.md");
|
|
208
|
+
if (existsSync(promptPath)) {
|
|
209
|
+
const prompt = readFileSync(promptPath, "utf-8");
|
|
210
|
+
return `continue\n\n${prompt}`;
|
|
211
|
+
}
|
|
212
|
+
return "continue";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Private Methods ────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
private ensureDir(): void {
|
|
218
|
+
if (!existsSync(this.rootDir)) {
|
|
219
|
+
mkdirSync(this.rootDir, { recursive: true });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private generateSummary(event: CompactionEvent): string {
|
|
224
|
+
return `# Compaction Summary
|
|
225
|
+
|
|
226
|
+
**Time:** ${event.timestamp}
|
|
227
|
+
**Job:** ${event.jobId}
|
|
228
|
+
**Task:** ${event.taskId}
|
|
229
|
+
**Reason:** ${event.reason}
|
|
230
|
+
**Tokens Compacted:** ${event.compactedFromTokens.toLocaleString()}
|
|
231
|
+
|
|
232
|
+
${event.errorMessage ? `**Error:** ${event.errorMessage}` : ""}
|
|
233
|
+
|
|
234
|
+
## What Happened
|
|
235
|
+
|
|
236
|
+
Session was compacted due to ${event.reason}.
|
|
237
|
+
|
|
238
|
+
${
|
|
239
|
+
event.compactedFromTokens > 0
|
|
240
|
+
? `Context was reduced from approximately ${event.compactedFromTokens.toLocaleString()} tokens.`
|
|
241
|
+
: "Context was compacted."
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
## Next Step
|
|
245
|
+
|
|
246
|
+
Runtime will automatically continue this task.
|
|
247
|
+
|
|
248
|
+
Continue attempts: ${this.continueAttempts}/${this.maxAttempts}
|
|
249
|
+
`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private formatContinuePrompt(prompt: ContinuePrompt): string {
|
|
253
|
+
const lines = [
|
|
254
|
+
"# Continue Previous Task",
|
|
255
|
+
"",
|
|
256
|
+
"## Task Context",
|
|
257
|
+
"",
|
|
258
|
+
"**Requirement:** " + prompt.originalRequirement,
|
|
259
|
+
"",
|
|
260
|
+
"## What Was Completed",
|
|
261
|
+
"",
|
|
262
|
+
prompt.whatWasCompleted ||
|
|
263
|
+
"Work was in progress when session was compacted.",
|
|
264
|
+
"",
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
if (prompt.partialFiles.length > 0) {
|
|
268
|
+
lines.push("## Partial Files Created");
|
|
269
|
+
lines.push("");
|
|
270
|
+
for (const file of prompt.partialFiles) {
|
|
271
|
+
lines.push(`- ${file}`);
|
|
272
|
+
}
|
|
273
|
+
lines.push("");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
lines.push("## What Needs To Be Done");
|
|
277
|
+
lines.push("");
|
|
278
|
+
lines.push(
|
|
279
|
+
prompt.whatNeedsToBeDone || "Continue the task from where it left off.",
|
|
280
|
+
);
|
|
281
|
+
lines.push("");
|
|
282
|
+
lines.push("## Instructions");
|
|
283
|
+
lines.push("");
|
|
284
|
+
lines.push("1. Review any partial files created");
|
|
285
|
+
lines.push("2. Continue from where the previous session ended");
|
|
286
|
+
lines.push("3. Complete the remaining work");
|
|
287
|
+
lines.push("4. Ensure all tests pass");
|
|
288
|
+
lines.push("");
|
|
289
|
+
lines.push("**Do not repeat work that was already completed.**");
|
|
290
|
+
|
|
291
|
+
return lines.join("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private getRequirement(): string {
|
|
295
|
+
const checkpointPath = join(
|
|
296
|
+
homedir(),
|
|
297
|
+
".pi",
|
|
298
|
+
"harness",
|
|
299
|
+
this.jobId,
|
|
300
|
+
"checkpoint.json",
|
|
301
|
+
);
|
|
302
|
+
if (existsSync(checkpointPath)) {
|
|
303
|
+
try {
|
|
304
|
+
const checkpoint = JSON.parse(readFileSync(checkpointPath, "utf-8"));
|
|
305
|
+
return checkpoint.requirement ?? "Unknown requirement";
|
|
306
|
+
} catch {
|
|
307
|
+
return "Unknown requirement";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return "Unknown requirement";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private findPartialFiles(): string[] {
|
|
314
|
+
const partialDir = join(this.rootDir, "..", "partial", this.jobId);
|
|
315
|
+
if (!existsSync(partialDir)) {
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const files = readFileSync(join(partialDir, "files.json"), "utf-8");
|
|
321
|
+
return JSON.parse(files) as string[];
|
|
322
|
+
} catch {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E Test Engine — RFC-0013
|
|
3
|
+
*
|
|
4
|
+
* Browser-based end-to-end testing before work is marked ready for client.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { E2ETestEngine, PlaywrightE2ERunner } from "./e2e/index.js";
|
|
9
|
+
*
|
|
10
|
+
* const runner = new PlaywrightE2ERunner({ headless: true });
|
|
11
|
+
* await runner.start();
|
|
12
|
+
*
|
|
13
|
+
* const engine = new E2ETestEngine("/tmp/e2e", {
|
|
14
|
+
* baseUrl: "http://localhost:3000",
|
|
15
|
+
* screenshotOnFailure: true,
|
|
16
|
+
* });
|
|
17
|
+
* engine.setRunner(runner);
|
|
18
|
+
*
|
|
19
|
+
* const scenario = engine.createScenario(
|
|
20
|
+
* "homepage-loads",
|
|
21
|
+
* "Homepage Loads",
|
|
22
|
+
* "Verify the homepage loads correctly",
|
|
23
|
+
* );
|
|
24
|
+
* engine.addStep(scenario, "navigate", { value: "http://localhost:3000" });
|
|
25
|
+
* engine.addStep(scenario, "wait", { selector: "h1" });
|
|
26
|
+
* engine.addStep(scenario, "assert", { assertCondition: "document.querySelector('h1') !== null" });
|
|
27
|
+
*
|
|
28
|
+
* const result = await engine.runScenario(scenario);
|
|
29
|
+
* console.log(result);
|
|
30
|
+
*
|
|
31
|
+
* await runner.stop();
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export { E2ETestEngine } from "./test-engine.js";
|
|
36
|
+
export {
|
|
37
|
+
PlaywrightE2ERunner,
|
|
38
|
+
MiniMaxQuotaScraper,
|
|
39
|
+
} from "./playwright-runner.js";
|
|
40
|
+
export type {
|
|
41
|
+
E2ERunner,
|
|
42
|
+
PlaywrightRunnerConfig,
|
|
43
|
+
QuotaPageData,
|
|
44
|
+
} from "./playwright-runner.js";
|
|
@@ -7,35 +7,40 @@
|
|
|
7
7
|
* Also used as the E2E runner for testing workflows.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
// Dynamic import for Playwright (optional dependency)
|
|
11
|
+
let playwrightModule: typeof import("playwright") | null = null;
|
|
12
|
+
|
|
13
|
+
async function getPlaywright() {
|
|
14
|
+
if (!playwrightModule) {
|
|
15
|
+
try {
|
|
16
|
+
playwrightModule = await import("playwright");
|
|
17
|
+
} catch {
|
|
18
|
+
console.warn(
|
|
19
|
+
"[PlaywrightRunner] Playwright not installed. E2E tests will be skipped.",
|
|
20
|
+
);
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return playwrightModule;
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
export interface PlaywrightRunnerConfig {
|
|
11
28
|
headless?: boolean;
|
|
12
29
|
slowMo?: number;
|
|
13
30
|
timeout?: number;
|
|
14
31
|
browser?: "chromium" | "firefox" | "webkit";
|
|
15
32
|
profileDir?: string;
|
|
33
|
+
viewport?: { width: number; height: number };
|
|
16
34
|
}
|
|
17
35
|
|
|
18
|
-
|
|
19
|
-
|
|
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>;
|
|
36
|
+
// Forward-compatible runner interface for E2E Test Engine
|
|
37
|
+
export interface E2ERunner {
|
|
38
|
+
navigate(url: string): Promise<void>;
|
|
29
39
|
click(selector: string): Promise<void>;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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>;
|
|
40
|
+
type(selector: string, text: string): Promise<void>;
|
|
41
|
+
wait(selector: string, timeout?: number): Promise<void>;
|
|
42
|
+
screenshot(path: string): Promise<void>;
|
|
43
|
+
assert(condition: string, message?: string): Promise<boolean>;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
export interface QuotaPageData {
|
|
@@ -81,10 +86,7 @@ export class MiniMaxQuotaScraper {
|
|
|
81
86
|
/**
|
|
82
87
|
* Wait for quota data to load
|
|
83
88
|
*/
|
|
84
|
-
async waitForQuotaLoad(
|
|
85
|
-
page: PlaywrightPage,
|
|
86
|
-
timeoutMs?: number,
|
|
87
|
-
): Promise<void> {
|
|
89
|
+
async waitForQuotaLoad(page: any, timeoutMs?: number): Promise<void> {
|
|
88
90
|
const selectors = [
|
|
89
91
|
".quota-used",
|
|
90
92
|
".usage-percentage",
|
|
@@ -133,11 +135,14 @@ export class MiniMaxQuotaScraper {
|
|
|
133
135
|
|
|
134
136
|
/**
|
|
135
137
|
* Playwright E2E Runner for testing workflows
|
|
138
|
+
*
|
|
139
|
+
* Real implementation using Playwright library.
|
|
136
140
|
*/
|
|
137
|
-
export class PlaywrightE2ERunner {
|
|
141
|
+
export class PlaywrightE2ERunner implements E2ERunner {
|
|
138
142
|
private config: PlaywrightRunnerConfig;
|
|
139
|
-
private browser:
|
|
140
|
-
private
|
|
143
|
+
private browser: any = null;
|
|
144
|
+
private context: any = null;
|
|
145
|
+
private page: any = null;
|
|
141
146
|
|
|
142
147
|
constructor(config: PlaywrightRunnerConfig = {}) {
|
|
143
148
|
this.config = {
|
|
@@ -153,9 +158,30 @@ export class PlaywrightE2ERunner {
|
|
|
153
158
|
* Start the browser
|
|
154
159
|
*/
|
|
155
160
|
async start(): Promise<void> {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
const pw = await getPlaywright();
|
|
162
|
+
if (!pw) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
"Playwright not available. Install with: bun add playwright",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const browserType =
|
|
169
|
+
this.config.browser === "firefox"
|
|
170
|
+
? pw.firefox
|
|
171
|
+
: this.config.browser === "webkit"
|
|
172
|
+
? pw.webkit
|
|
173
|
+
: pw.chromium;
|
|
174
|
+
|
|
175
|
+
this.browser = await browserType.launch({
|
|
176
|
+
headless: this.config.headless,
|
|
177
|
+
slowMo: this.config.slowMo,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
this.context = await this.browser.newContext({
|
|
181
|
+
viewport: this.config.viewport ?? { width: 1280, height: 720 },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
this.page = await this.context.newPage();
|
|
159
185
|
}
|
|
160
186
|
|
|
161
187
|
/**
|
|
@@ -163,11 +189,15 @@ export class PlaywrightE2ERunner {
|
|
|
163
189
|
*/
|
|
164
190
|
async stop(): Promise<void> {
|
|
165
191
|
if (this.page) {
|
|
166
|
-
|
|
192
|
+
await this.page.close();
|
|
167
193
|
this.page = null;
|
|
168
194
|
}
|
|
195
|
+
if (this.context) {
|
|
196
|
+
await this.context.close();
|
|
197
|
+
this.context = null;
|
|
198
|
+
}
|
|
169
199
|
if (this.browser) {
|
|
170
|
-
|
|
200
|
+
await this.browser.close();
|
|
171
201
|
this.browser = null;
|
|
172
202
|
}
|
|
173
203
|
}
|
|
@@ -177,7 +207,7 @@ export class PlaywrightE2ERunner {
|
|
|
177
207
|
*/
|
|
178
208
|
async navigate(url: string): Promise<void> {
|
|
179
209
|
if (!this.page) throw new Error("Browser not started");
|
|
180
|
-
await this.page.goto(url);
|
|
210
|
+
await this.page.goto(url, { timeout: this.config.timeout });
|
|
181
211
|
}
|
|
182
212
|
|
|
183
213
|
/**
|
|
@@ -190,12 +220,12 @@ export class PlaywrightE2ERunner {
|
|
|
190
220
|
}
|
|
191
221
|
|
|
192
222
|
/**
|
|
193
|
-
*
|
|
223
|
+
* Type text into an input
|
|
194
224
|
*/
|
|
195
|
-
async
|
|
225
|
+
async type(selector: string, text: string): Promise<void> {
|
|
196
226
|
if (!this.page) throw new Error("Browser not started");
|
|
197
227
|
await this.page.waitForSelector(selector, { timeout: this.config.timeout });
|
|
198
|
-
await this.page.fill(selector,
|
|
228
|
+
await this.page.fill(selector, text);
|
|
199
229
|
}
|
|
200
230
|
|
|
201
231
|
/**
|
|
@@ -213,23 +243,7 @@ export class PlaywrightE2ERunner {
|
|
|
213
243
|
*/
|
|
214
244
|
async screenshot(path: string): Promise<void> {
|
|
215
245
|
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();
|
|
246
|
+
await this.page.screenshot({ path, fullPage: true });
|
|
233
247
|
}
|
|
234
248
|
|
|
235
249
|
/**
|
|
@@ -249,4 +263,29 @@ export class PlaywrightE2ERunner {
|
|
|
249
263
|
|
|
250
264
|
return result;
|
|
251
265
|
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get the underlying page for advanced operations
|
|
269
|
+
*/
|
|
270
|
+
getPage() {
|
|
271
|
+
return this.page;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Start tracing for debugging
|
|
276
|
+
*/
|
|
277
|
+
async startTracing(_outputPath: string): Promise<void> {
|
|
278
|
+
if (!this.page) throw new Error("Browser not started");
|
|
279
|
+
await this.page
|
|
280
|
+
.context()
|
|
281
|
+
.tracing.start({ screenshots: true, snapshots: true });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Stop tracing and save
|
|
286
|
+
*/
|
|
287
|
+
async stopTracing(outputPath: string): Promise<void> {
|
|
288
|
+
if (!this.page) throw new Error("Browser not started");
|
|
289
|
+
await this.page.context().tracing.stop({ path: outputPath });
|
|
290
|
+
}
|
|
252
291
|
}
|
|
@@ -18,19 +18,15 @@ import type {
|
|
|
18
18
|
E2EStep,
|
|
19
19
|
E2EResult,
|
|
20
20
|
E2EReport,
|
|
21
|
-
} from "../../packages/types/src/runtime-types.
|
|
22
|
-
import { writeJson, appendJsonl } from "../../cli.
|
|
23
|
-
// @ts-expect-error - Bun has built-in Node.js types
|
|
21
|
+
} from "../../packages/types/src/runtime-types.js";
|
|
22
|
+
import { writeJson, appendJsonl } from "../../cli.js";
|
|
24
23
|
import { join } from "node:path";
|
|
25
24
|
|
|
26
|
-
export
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
screenshot(path: string): Promise<void>;
|
|
32
|
-
assert(condition: string, message?: string): Promise<boolean>;
|
|
33
|
-
}
|
|
25
|
+
// Re-export from playwright-runner for backwards compatibility
|
|
26
|
+
export type { E2ERunner as PlaywrightRunner } from "./playwright-runner.js";
|
|
27
|
+
|
|
28
|
+
// Also import locally for use in this file
|
|
29
|
+
import type { E2ERunner } from "./playwright-runner.js";
|
|
34
30
|
|
|
35
31
|
export interface E2EConfig {
|
|
36
32
|
baseUrl: string;
|
|
@@ -44,7 +40,7 @@ export interface E2EConfig {
|
|
|
44
40
|
export class E2ETestEngine {
|
|
45
41
|
private readonly rootDir: string;
|
|
46
42
|
private readonly config: E2EConfig;
|
|
47
|
-
private runner:
|
|
43
|
+
private runner: E2ERunner | null = null;
|
|
48
44
|
|
|
49
45
|
constructor(rootDir: string, config: E2EConfig) {
|
|
50
46
|
this.rootDir = rootDir;
|
|
@@ -61,7 +57,7 @@ export class E2ETestEngine {
|
|
|
61
57
|
/**
|
|
62
58
|
* Set the Playwright runner
|
|
63
59
|
*/
|
|
64
|
-
setRunner(runner:
|
|
60
|
+
setRunner(runner: E2ERunner): void {
|
|
65
61
|
this.runner = runner;
|
|
66
62
|
}
|
|
67
63
|
|