creevey 0.10.35 → 0.10.36
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/.release-please-manifest.json +3 -0
- package/CHANGELOG.md +1936 -1046
- package/cliff.toml +63 -0
- package/dist/cli.js +0 -0
- package/dist/server/reporters/junit.d.ts +6 -0
- package/dist/server/reporters/junit.js +93 -18
- package/dist/server/reporters/junit.js.map +1 -1
- package/docs/superpowers/plans/2026-05-12-junit-reporter-improvements.md +927 -0
- package/docs/superpowers/plans/2026-05-13-release-please-migration.md +319 -0
- package/docs/superpowers/specs/2026-05-12-junit-reporter-improvements-design.md +173 -0
- package/docs/superpowers/specs/2026-05-13-release-please-migration-design.md +205 -0
- package/memories/workflow.md +18 -9
- package/package.json +11 -7
- package/release-please-config.json +18 -0
- package/src/server/reporters/junit.ts +122 -34
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
# JUnit Reporter Improvements — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **Status: ✅ COMPLETED** (2026-05-12) — All 6 tasks implemented, 38/38 tests passing, TypeScript clean.
|
|
4
|
+
|
|
5
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
|
6
|
+
|
|
7
|
+
**Goal:** Upgrade `JUnitReporter` in `src/server/reporters/junit.ts` to be fully compatible with modern CI/CD tools (Jenkins, TeamCity, GitHub Actions, Allure, TestMo, etc.) by fixing suite keying, failure/error bodies, screenshot attachments, and spec-required attributes.
|
|
8
|
+
|
|
9
|
+
**Architecture:** All changes are confined to a single file (`src/server/reporters/junit.ts`). Tests live in `tests/reporters/junit.test.ts` and exercise the reporter end-to-end by emitting `EventEmitter` events and asserting the resulting XML file content.
|
|
10
|
+
|
|
11
|
+
**Tech Stack:** TypeScript, Node.js `os`/`path`/`fs` (built-ins), Vitest
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File Structure
|
|
16
|
+
|
|
17
|
+
| File | Action | Purpose |
|
|
18
|
+
| ------------------------------- | ------ | ----------------------------------------- |
|
|
19
|
+
| `src/server/reporters/junit.ts` | Modify | All implementation changes |
|
|
20
|
+
| `tests/reporters/junit.test.ts` | Create | New test file — end-to-end XML assertions |
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Task 1: Test Infrastructure + `writeElement` Text Content
|
|
25
|
+
|
|
26
|
+
**Files:**
|
|
27
|
+
|
|
28
|
+
- Create: `tests/reporters/junit.test.ts`
|
|
29
|
+
- Modify: `src/server/reporters/junit.ts`
|
|
30
|
+
|
|
31
|
+
### Context
|
|
32
|
+
|
|
33
|
+
`writeElement` currently only accepts `children?: () => void`. Failure and error elements need to write text between their tags. We extend it with an optional 4th param `textContent?: string`.
|
|
34
|
+
|
|
35
|
+
- [x] **Step 1: Create the test file with helpers and a smoke test**
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
// tests/reporters/junit.test.ts
|
|
39
|
+
import { describe, test, expect, afterEach } from 'vitest';
|
|
40
|
+
import EventEmitter from 'events';
|
|
41
|
+
import { join, dirname } from 'path';
|
|
42
|
+
import { tmpdir } from 'os';
|
|
43
|
+
import { readFileSync, unlinkSync, existsSync } from 'fs';
|
|
44
|
+
import { randomUUID } from 'crypto';
|
|
45
|
+
import { TEST_EVENTS } from '../../src/types.js';
|
|
46
|
+
import { JUnitReporter } from '../../src/server/reporters/junit.js';
|
|
47
|
+
import type { FakeTest, FakeSuite, Images } from '../../src/types.js';
|
|
48
|
+
|
|
49
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function tempXmlPath(): string {
|
|
52
|
+
return join(tmpdir(), `junit-test-${randomUUID()}.xml`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface FakeTestOptions {
|
|
56
|
+
storyTitle?: string;
|
|
57
|
+
testTitle?: string;
|
|
58
|
+
browserName?: string;
|
|
59
|
+
state?: 'passed' | 'failed';
|
|
60
|
+
err?: string;
|
|
61
|
+
duration?: number;
|
|
62
|
+
attachments?: string[];
|
|
63
|
+
images?: Partial<Record<string, Partial<Images>>>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeFakeTest(opts: FakeTestOptions = {}): FakeTest {
|
|
67
|
+
const storyTitle = opts.storyTitle ?? 'My Story';
|
|
68
|
+
const testTitle = opts.testTitle ?? 'my test';
|
|
69
|
+
const browserName = opts.browserName ?? 'chrome';
|
|
70
|
+
|
|
71
|
+
const suite: FakeSuite = {
|
|
72
|
+
title: storyTitle,
|
|
73
|
+
fullTitle: () => storyTitle,
|
|
74
|
+
titlePath: () => [storyTitle],
|
|
75
|
+
tests: [],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
parent: suite,
|
|
80
|
+
title: testTitle,
|
|
81
|
+
fullTitle: () => `${storyTitle} ${testTitle}`,
|
|
82
|
+
titlePath: () => [storyTitle, testTitle],
|
|
83
|
+
currentRetry: () => 0,
|
|
84
|
+
retires: () => 0,
|
|
85
|
+
slow: () => 0,
|
|
86
|
+
duration: opts.duration ?? 100,
|
|
87
|
+
state: opts.state ?? 'passed',
|
|
88
|
+
err: opts.err,
|
|
89
|
+
attachments: opts.attachments,
|
|
90
|
+
creevey: {
|
|
91
|
+
testId: `${storyTitle}/${testTitle}`,
|
|
92
|
+
sessionId: 'session-1',
|
|
93
|
+
browserName,
|
|
94
|
+
workerId: 1,
|
|
95
|
+
willRetry: false,
|
|
96
|
+
images: opts.images ?? {},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function runReporter(tests: FakeTest[], outputFile: string): string {
|
|
102
|
+
const runner = new EventEmitter();
|
|
103
|
+
new JUnitReporter(runner, {
|
|
104
|
+
reportDir: dirname(outputFile),
|
|
105
|
+
reporterOptions: { outputFile },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
runner.emit(TEST_EVENTS.RUN_BEGIN);
|
|
109
|
+
for (const test of tests) {
|
|
110
|
+
runner.emit(TEST_EVENTS.TEST_BEGIN, test);
|
|
111
|
+
if (test.state === 'passed') {
|
|
112
|
+
runner.emit(TEST_EVENTS.TEST_PASS, test);
|
|
113
|
+
} else {
|
|
114
|
+
runner.emit(TEST_EVENTS.TEST_FAIL, test);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
runner.emit(TEST_EVENTS.RUN_END);
|
|
118
|
+
|
|
119
|
+
return readFileSync(outputFile, 'utf-8');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const createdFiles: string[] = [];
|
|
123
|
+
function track(p: string): string {
|
|
124
|
+
createdFiles.push(p);
|
|
125
|
+
return p;
|
|
126
|
+
}
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
for (const p of createdFiles.splice(0)) {
|
|
129
|
+
if (existsSync(p)) unlinkSync(p);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── smoke test ────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe('JUnitReporter', () => {
|
|
136
|
+
test('produces valid XML for a single passing test', () => {
|
|
137
|
+
const out = track(tempXmlPath());
|
|
138
|
+
const xml = runReporter([makeFakeTest()], out);
|
|
139
|
+
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8" ?>');
|
|
140
|
+
expect(xml).toContain('<testsuites');
|
|
141
|
+
expect(xml).toContain('<testsuite');
|
|
142
|
+
expect(xml).toContain('<testcase');
|
|
143
|
+
expect(xml).toContain('name="my test"');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- [x] **Step 2: Run the smoke test to confirm it passes** (it tests existing behavior)
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
yarn test tests/reporters/junit.test.ts
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Expected: 1 test passes.
|
|
155
|
+
|
|
156
|
+
- [x] **Step 3: Write a failing test for `writeElement` text content — failure element with body**
|
|
157
|
+
|
|
158
|
+
Add this `describe` block inside the outer `describe('JUnitReporter', ...)`:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
describe('failure body', () => {
|
|
162
|
+
test('writes failure element with text body when images are present', () => {
|
|
163
|
+
const out = track(tempXmlPath());
|
|
164
|
+
const test = makeFakeTest({
|
|
165
|
+
state: 'failed',
|
|
166
|
+
images: { header: { error: 'images differ by 5%' } },
|
|
167
|
+
});
|
|
168
|
+
const xml = runReporter([test], out);
|
|
169
|
+
expect(xml).toContain('<failure');
|
|
170
|
+
expect(xml).toContain('header: images differ by 5%');
|
|
171
|
+
expect(xml).toContain('</failure>');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
- [x] **Step 4: Run to confirm it fails**
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
yarn test tests/reporters/junit.test.ts
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Expected: FAIL — the failure element currently has no text body.
|
|
183
|
+
|
|
184
|
+
- [x] **Step 5: Extend `writeElement` with `textContent` parameter**
|
|
185
|
+
|
|
186
|
+
In `src/server/reporters/junit.ts`, change the `writeElement` signature and body:
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
private writeElement(
|
|
190
|
+
name: string,
|
|
191
|
+
attrs: Record<string, string | number | undefined>,
|
|
192
|
+
children?: () => void,
|
|
193
|
+
textContent?: string,
|
|
194
|
+
): void {
|
|
195
|
+
const pairs: string[] = [];
|
|
196
|
+
for (const key in attrs) {
|
|
197
|
+
const attr = attrs[key];
|
|
198
|
+
if (attr === undefined) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
pairs.push(`${key}="${escapeXML(attr)}"`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.logger.log(`<${name}${pairs.length ? ` ${pairs.join(' ')}` : ''}>`);
|
|
205
|
+
this.logger.indent();
|
|
206
|
+
if (textContent !== undefined) {
|
|
207
|
+
this.logger.log(escapeXML(textContent));
|
|
208
|
+
} else {
|
|
209
|
+
children?.call(this);
|
|
210
|
+
}
|
|
211
|
+
this.logger.unindent();
|
|
212
|
+
this.logger.log(`</${name}>`);
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- [x] **Step 6: Run tests — still fails** (the reporter's `writeTasks` still doesn't use `textContent`)
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
yarn test tests/reporters/junit.test.ts
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Expected: still FAIL — we haven't updated `writeTasks` yet. That's OK — we'll do it in Task 3.
|
|
223
|
+
|
|
224
|
+
- [x] **Step 7: Commit the writeElement extension**
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
git add src/server/reporters/junit.ts tests/reporters/junit.test.ts
|
|
228
|
+
git commit -m "feat(junit): extend writeElement with textContent parameter
|
|
229
|
+
|
|
230
|
+
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Task 2: Suite Keying Fix (multi-browser)
|
|
236
|
+
|
|
237
|
+
**Files:**
|
|
238
|
+
|
|
239
|
+
- Modify: `src/server/reporters/junit.ts`
|
|
240
|
+
- Modify: `tests/reporters/junit.test.ts`
|
|
241
|
+
|
|
242
|
+
### Context
|
|
243
|
+
|
|
244
|
+
Currently `suites` and `suiteStartTimes` are keyed by story name only (`test.parent.title`). When the same story runs in multiple browsers, all browsers merge into one `<testsuite>` entry — the later write for browser B overwrites the entry from browser A. The fix: key internally by `"${suiteName}/${browserName}"` and store `suiteName` + `browserName` in the entry. Each browser produces its own `<testsuite>` block.
|
|
245
|
+
|
|
246
|
+
- [x] **Step 1: Write failing test for multi-browser suite keying**
|
|
247
|
+
|
|
248
|
+
Add inside `describe('JUnitReporter', ...)`:
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
describe('multi-browser suites', () => {
|
|
252
|
+
test('creates separate testsuite elements for each browser', () => {
|
|
253
|
+
const out = track(tempXmlPath());
|
|
254
|
+
const chromeTest = makeFakeTest({ storyTitle: 'Button', browserName: 'chrome' });
|
|
255
|
+
const firefoxTest = makeFakeTest({ storyTitle: 'Button', browserName: 'firefox' });
|
|
256
|
+
const xml = runReporter([chromeTest, firefoxTest], out);
|
|
257
|
+
|
|
258
|
+
// Should have TWO <testsuite> elements, one per browser
|
|
259
|
+
const suiteMatches = xml.match(/<testsuite /g) ?? [];
|
|
260
|
+
expect(suiteMatches.length).toBe(2);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('adds browser property to each testsuite', () => {
|
|
264
|
+
const out = track(tempXmlPath());
|
|
265
|
+
const test = makeFakeTest({ storyTitle: 'Button', browserName: 'chrome' });
|
|
266
|
+
const xml = runReporter([test], out);
|
|
267
|
+
|
|
268
|
+
expect(xml).toContain('<property name="browser" value="chrome"');
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
- [x] **Step 2: Run to confirm multi-browser test fails**
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
yarn test tests/reporters/junit.test.ts
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Expected: FAIL on `suiteMatches.length` (currently 1, expected 2) and missing browser property.
|
|
280
|
+
|
|
281
|
+
- [x] **Step 3: Add `SuiteEntry` interface and update `suites` field type**
|
|
282
|
+
|
|
283
|
+
In `src/server/reporters/junit.ts`, add the `SuiteEntry` interface after imports (before the `IndentedLogger` class):
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
interface SuiteEntry {
|
|
287
|
+
suiteName: string;
|
|
288
|
+
browserName: string;
|
|
289
|
+
tests: Map<string, FakeTest>;
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Change the `suites` field declaration:
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
// OLD:
|
|
297
|
+
private suites: Record<string, Map<string, FakeTest>> = {};
|
|
298
|
+
|
|
299
|
+
// NEW:
|
|
300
|
+
private suites: Record<string, SuiteEntry> = {};
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
- [x] **Step 4: Update the three event handlers in the constructor**
|
|
304
|
+
|
|
305
|
+
Replace all three event handlers (`TEST_BEGIN`, `TEST_PASS`, `TEST_FAIL`) with the keyed versions:
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
runner.on(TEST_EVENTS.TEST_BEGIN, (test: FakeTest) => {
|
|
309
|
+
const key = `${test.parent.title}/${test.creevey.browserName}`;
|
|
310
|
+
this.suiteStartTimes[key] ??= new Date();
|
|
311
|
+
});
|
|
312
|
+
runner.on(TEST_EVENTS.TEST_PASS, (test: FakeTest) => {
|
|
313
|
+
const key = `${test.parent.title}/${test.creevey.browserName}`;
|
|
314
|
+
if (!this.suites[key]) {
|
|
315
|
+
this.suites[key] = { suiteName: test.parent.title, browserName: test.creevey.browserName, tests: new Map() };
|
|
316
|
+
}
|
|
317
|
+
this.suites[key].tests.set(test.creevey.testId, test);
|
|
318
|
+
});
|
|
319
|
+
runner.on(TEST_EVENTS.TEST_FAIL, (test: FakeTest) => {
|
|
320
|
+
const key = `${test.parent.title}/${test.creevey.browserName}`;
|
|
321
|
+
if (!this.suites[key]) {
|
|
322
|
+
this.suites[key] = { suiteName: test.parent.title, browserName: test.creevey.browserName, tests: new Map() };
|
|
323
|
+
}
|
|
324
|
+
this.suites[key].tests.set(test.creevey.testId, test);
|
|
325
|
+
});
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
- [x] **Step 5: Update `onFinished` to use the new `SuiteEntry` structure**
|
|
329
|
+
|
|
330
|
+
Replace the `suites` local variable computation and the `testsuite` write block in `onFinished`:
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
const suites = Object.entries(this.suites).map(([key, { suiteName, browserName, tests }]) => {
|
|
334
|
+
let failures = 0;
|
|
335
|
+
let time = 0;
|
|
336
|
+
for (const [, test] of tests) {
|
|
337
|
+
if (test.state === 'failed') {
|
|
338
|
+
failures++;
|
|
339
|
+
}
|
|
340
|
+
time += test.duration ?? 0;
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
key,
|
|
344
|
+
suiteName,
|
|
345
|
+
browserName,
|
|
346
|
+
tests,
|
|
347
|
+
failures,
|
|
348
|
+
time,
|
|
349
|
+
timestamp: toISO8601(this.suiteStartTimes[key] ?? this.runStartTime),
|
|
350
|
+
};
|
|
351
|
+
});
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
And the `testsuites` element's inner `suites.forEach`:
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
suites.forEach(({ suiteName, browserName, tests, failures, time, timestamp }) => {
|
|
358
|
+
this.writeElement(
|
|
359
|
+
'testsuite',
|
|
360
|
+
{
|
|
361
|
+
name: suiteName,
|
|
362
|
+
tests: tests.size,
|
|
363
|
+
failures,
|
|
364
|
+
time: executionTime(time),
|
|
365
|
+
timestamp,
|
|
366
|
+
},
|
|
367
|
+
() => {
|
|
368
|
+
this.writeElement('properties', {}, () => {
|
|
369
|
+
this.writeElement('property', { name: 'browser', value: browserName });
|
|
370
|
+
});
|
|
371
|
+
this.writeTasks(tests);
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
- [x] **Step 6: Run tests**
|
|
378
|
+
|
|
379
|
+
```bash
|
|
380
|
+
yarn test tests/reporters/junit.test.ts
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Expected: all previously passing tests still pass; the new multi-browser tests pass too. The failure-body test from Task 1 still fails (expected — we'll fix that in Task 3).
|
|
384
|
+
|
|
385
|
+
- [x] **Step 7: Commit**
|
|
386
|
+
|
|
387
|
+
```bash
|
|
388
|
+
git add src/server/reporters/junit.ts tests/reporters/junit.test.ts
|
|
389
|
+
git commit -m "feat(junit): fix suite keying for multi-browser runs
|
|
390
|
+
|
|
391
|
+
Key suites by 'storyName/browserName' so each browser produces its
|
|
392
|
+
own <testsuite> block. Add <property name=\"browser\"> to each suite.
|
|
393
|
+
|
|
394
|
+
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## Task 3: Failure Body & Errors vs Failures Distinction
|
|
400
|
+
|
|
401
|
+
**Files:**
|
|
402
|
+
|
|
403
|
+
- Modify: `src/server/reporters/junit.ts`
|
|
404
|
+
- Modify: `tests/reporters/junit.test.ts`
|
|
405
|
+
|
|
406
|
+
### Context
|
|
407
|
+
|
|
408
|
+
The spec distinguishes:
|
|
409
|
+
|
|
410
|
+
- **`<failure>`**: image diff mismatch — `test.creevey.images` has entries
|
|
411
|
+
- **`<error>`**: unexpected crash — `test.err` set, no images
|
|
412
|
+
|
|
413
|
+
`<testsuite>` and `<testsuites>` should report both `failures` and `errors` attributes separately.
|
|
414
|
+
|
|
415
|
+
- [x] **Step 1: Write failing tests for failure body and error distinction**
|
|
416
|
+
|
|
417
|
+
Add inside `describe('JUnitReporter', ...)`:
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
describe('failure and error elements', () => {
|
|
421
|
+
test('writes failure with per-step body for image mismatches', () => {
|
|
422
|
+
const out = track(tempXmlPath());
|
|
423
|
+
const t = makeFakeTest({
|
|
424
|
+
state: 'failed',
|
|
425
|
+
images: {
|
|
426
|
+
header: { error: 'header differs by 3%' },
|
|
427
|
+
body: { actual: '/tmp/body.png' }, // no error string
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
const xml = runReporter([t], out);
|
|
431
|
+
expect(xml).toContain('<failure');
|
|
432
|
+
expect(xml).toContain('message="Images do not match"');
|
|
433
|
+
expect(xml).toContain('header: header differs by 3%');
|
|
434
|
+
expect(xml).toContain('body: expected and actual images differ');
|
|
435
|
+
expect(xml).toContain('</failure>');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('writes error element for crash with no images', () => {
|
|
439
|
+
const out = track(tempXmlPath());
|
|
440
|
+
const t = makeFakeTest({
|
|
441
|
+
state: 'failed',
|
|
442
|
+
err: 'TypeError: Cannot read properties of null',
|
|
443
|
+
});
|
|
444
|
+
const xml = runReporter([t], out);
|
|
445
|
+
expect(xml).toContain('<error');
|
|
446
|
+
expect(xml).toContain('message="TypeError: Cannot read properties of null"');
|
|
447
|
+
expect(xml).toContain('TypeError: Cannot read properties of null');
|
|
448
|
+
expect(xml).toContain('</error>');
|
|
449
|
+
expect(xml).not.toContain('<failure');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('counts errors and failures separately on testsuite', () => {
|
|
453
|
+
const out = track(tempXmlPath());
|
|
454
|
+
const imageFail = makeFakeTest({
|
|
455
|
+
storyTitle: 'Story',
|
|
456
|
+
testTitle: 'img fail',
|
|
457
|
+
state: 'failed',
|
|
458
|
+
images: { header: {} },
|
|
459
|
+
});
|
|
460
|
+
const crash = makeFakeTest({
|
|
461
|
+
storyTitle: 'Story',
|
|
462
|
+
testTitle: 'crash',
|
|
463
|
+
state: 'failed',
|
|
464
|
+
err: 'boom',
|
|
465
|
+
});
|
|
466
|
+
const xml = runReporter([imageFail, crash], out);
|
|
467
|
+
// 1 failure (image) + 1 error (crash)
|
|
468
|
+
expect(xml).toMatch(/failures="1"/);
|
|
469
|
+
expect(xml).toMatch(/errors="1"/);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('writes fallback failure when state is failed but no images and no err', () => {
|
|
473
|
+
const out = track(tempXmlPath());
|
|
474
|
+
const t = makeFakeTest({ state: 'failed' });
|
|
475
|
+
const xml = runReporter([t], out);
|
|
476
|
+
expect(xml).toContain('<failure');
|
|
477
|
+
expect(xml).toContain('message="Test failed"');
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
- [x] **Step 2: Run to confirm all four tests fail**
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
yarn test tests/reporters/junit.test.ts
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
Expected: 4 new FAIL results.
|
|
489
|
+
|
|
490
|
+
- [x] **Step 3: Rewrite `writeTasks` and update `onFinished` stats**
|
|
491
|
+
|
|
492
|
+
Replace `writeTasks` in `src/server/reporters/junit.ts`:
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
private writeTasks(tests: Map<string, FakeTest>): void {
|
|
496
|
+
for (const [, test] of tests) {
|
|
497
|
+
const classname = test.parent.title;
|
|
498
|
+
this.writeElement(
|
|
499
|
+
'testcase',
|
|
500
|
+
{
|
|
501
|
+
classname,
|
|
502
|
+
name: test.title,
|
|
503
|
+
time: getDuration(test),
|
|
504
|
+
},
|
|
505
|
+
() => {
|
|
506
|
+
if (test.state === 'failed') {
|
|
507
|
+
this.writeFailureOrError(test);
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private writeFailureOrError(test: FakeTest): void {
|
|
515
|
+
const images = test.creevey.images ?? {};
|
|
516
|
+
const imageEntries = Object.entries(images);
|
|
517
|
+
|
|
518
|
+
if (imageEntries.length > 0) {
|
|
519
|
+
const bodyLines = imageEntries.map(([step, img]) => `${step}: ${img?.error ?? 'expected and actual images differ'}`);
|
|
520
|
+
this.writeElement('failure', { message: 'Images do not match' }, undefined, bodyLines.join('\n'));
|
|
521
|
+
} else if (test.err) {
|
|
522
|
+
this.writeElement('error', { message: test.err, type: 'Error' }, undefined, test.err);
|
|
523
|
+
} else {
|
|
524
|
+
this.writeElement('failure', { message: 'Test failed' });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
- [x] **Step 4: Update `onFinished` to compute `errors` count separately**
|
|
530
|
+
|
|
531
|
+
Replace the `suites` computation's `failures` counting:
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
const suites = Object.entries(this.suites).map(([key, { suiteName, browserName, tests }]) => {
|
|
535
|
+
let failures = 0;
|
|
536
|
+
let errors = 0;
|
|
537
|
+
let time = 0;
|
|
538
|
+
for (const [, test] of tests) {
|
|
539
|
+
if (test.state === 'failed') {
|
|
540
|
+
const hasImages = Object.keys(test.creevey.images ?? {}).length > 0;
|
|
541
|
+
if (hasImages) failures++;
|
|
542
|
+
else errors++;
|
|
543
|
+
}
|
|
544
|
+
time += test.duration ?? 0;
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
key,
|
|
548
|
+
suiteName,
|
|
549
|
+
browserName,
|
|
550
|
+
tests,
|
|
551
|
+
failures,
|
|
552
|
+
errors,
|
|
553
|
+
time,
|
|
554
|
+
timestamp: toISO8601(this.suiteStartTimes[key] ?? this.runStartTime),
|
|
555
|
+
};
|
|
556
|
+
});
|
|
557
|
+
const stats = suites.reduce(
|
|
558
|
+
(s, { tests, failures, errors, time }) => {
|
|
559
|
+
s.tests += tests.size;
|
|
560
|
+
s.failures += failures;
|
|
561
|
+
s.errors += errors;
|
|
562
|
+
s.time += time;
|
|
563
|
+
return s;
|
|
564
|
+
},
|
|
565
|
+
{ name: 'creevey tests', tests: 0, failures: 0, errors: 0, time: 0 },
|
|
566
|
+
);
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Also update the `testsuite` element attributes in the `suites.forEach` to include `errors`:
|
|
570
|
+
|
|
571
|
+
```typescript
|
|
572
|
+
suites.forEach(({ suiteName, browserName, tests, failures, errors, time, timestamp }) => {
|
|
573
|
+
this.writeElement(
|
|
574
|
+
'testsuite',
|
|
575
|
+
{
|
|
576
|
+
name: suiteName,
|
|
577
|
+
tests: tests.size,
|
|
578
|
+
failures,
|
|
579
|
+
errors,
|
|
580
|
+
time: executionTime(time),
|
|
581
|
+
timestamp,
|
|
582
|
+
},
|
|
583
|
+
() => {
|
|
584
|
+
this.writeElement('properties', {}, () => {
|
|
585
|
+
this.writeElement('property', { name: 'browser', value: browserName });
|
|
586
|
+
});
|
|
587
|
+
this.writeTasks(tests);
|
|
588
|
+
},
|
|
589
|
+
);
|
|
590
|
+
});
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
- [x] **Step 5: Run all tests**
|
|
594
|
+
|
|
595
|
+
```bash
|
|
596
|
+
yarn test tests/reporters/junit.test.ts
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
Expected: all tests pass (including the smoke test and multi-browser tests from earlier tasks).
|
|
600
|
+
|
|
601
|
+
- [x] **Step 6: Commit**
|
|
602
|
+
|
|
603
|
+
```bash
|
|
604
|
+
git add src/server/reporters/junit.ts tests/reporters/junit.test.ts
|
|
605
|
+
git commit -m "feat(junit): add failure/error body text and separate errors count
|
|
606
|
+
|
|
607
|
+
- <failure> includes per-step image error lines for diff mismatches
|
|
608
|
+
- <error> element for crashes (test.err set, no images)
|
|
609
|
+
- testsuite and testsuites report failures and errors separately
|
|
610
|
+
- fallback <failure message=\"Test failed\"> for edge cases
|
|
611
|
+
|
|
612
|
+
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## Task 4: Screenshot Attachments
|
|
618
|
+
|
|
619
|
+
**Files:**
|
|
620
|
+
|
|
621
|
+
- Modify: `src/server/reporters/junit.ts`
|
|
622
|
+
- Modify: `tests/reporters/junit.test.ts`
|
|
623
|
+
|
|
624
|
+
### Context
|
|
625
|
+
|
|
626
|
+
`test.attachments` holds absolute paths to screenshot files. We write a `<properties>` block inside each `<testcase>` with one `<property name="attachment" value="relative/path.png"/>` per file. Paths are relative to the XML file's directory so CI artifact bundles are portable.
|
|
627
|
+
|
|
628
|
+
- [x] **Step 1: Write failing tests for screenshot attachments**
|
|
629
|
+
|
|
630
|
+
Add inside `describe('JUnitReporter', ...)`:
|
|
631
|
+
|
|
632
|
+
```typescript
|
|
633
|
+
describe('screenshot attachments', () => {
|
|
634
|
+
test('writes attachment properties relative to the report file', () => {
|
|
635
|
+
const out = track(tempXmlPath());
|
|
636
|
+
const absPath = join(dirname(out), 'Button', 'primary', 'chrome', 'button-actual-1.png');
|
|
637
|
+
const t = makeFakeTest({
|
|
638
|
+
state: 'failed',
|
|
639
|
+
images: { header: {} },
|
|
640
|
+
attachments: [absPath],
|
|
641
|
+
});
|
|
642
|
+
const xml = runReporter([t], out);
|
|
643
|
+
|
|
644
|
+
expect(xml).toContain('<properties>');
|
|
645
|
+
expect(xml).toContain('name="attachment"');
|
|
646
|
+
expect(xml).toContain('Button/primary/chrome/button-actual-1.png');
|
|
647
|
+
expect(xml).toContain('</properties>');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test('no attachment properties block when attachments is empty', () => {
|
|
651
|
+
const out = track(tempXmlPath());
|
|
652
|
+
const t = makeFakeTest({ state: 'passed', attachments: [] });
|
|
653
|
+
const xml = runReporter([t], out);
|
|
654
|
+
// The only <properties> block that may appear is in <testsuite> (browser prop) — not in testcase
|
|
655
|
+
// We verify there's no attachment property
|
|
656
|
+
expect(xml).not.toContain('name="attachment"');
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test('no attachment properties block when attachments is undefined', () => {
|
|
660
|
+
const out = track(tempXmlPath());
|
|
661
|
+
const t = makeFakeTest({ state: 'passed' }); // attachments not set
|
|
662
|
+
const xml = runReporter([t], out);
|
|
663
|
+
expect(xml).not.toContain('name="attachment"');
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
- [x] **Step 2: Run to confirm all three tests fail**
|
|
669
|
+
|
|
670
|
+
```bash
|
|
671
|
+
yarn test tests/reporters/junit.test.ts
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
Expected: FAIL on all 3 attachment tests.
|
|
675
|
+
|
|
676
|
+
- [x] **Step 3: Add `relative` import and update `writeTasks`**
|
|
677
|
+
|
|
678
|
+
At the top of `src/server/reporters/junit.ts`, the import of `path` functions already includes `resolve` and `dirname`. Add `relative`:
|
|
679
|
+
|
|
680
|
+
```typescript
|
|
681
|
+
// OLD:
|
|
682
|
+
import { dirname, resolve } from 'path';
|
|
683
|
+
|
|
684
|
+
// NEW:
|
|
685
|
+
import { dirname, relative, resolve } from 'path';
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
Update `writeTasks` to write the attachment `<properties>` block inside each `<testcase>` callback. Change the `writeTasks` method so that the `<testcase>` children callback also emits the attachment block before the failure/error:
|
|
689
|
+
|
|
690
|
+
```typescript
|
|
691
|
+
private writeTasks(tests: Map<string, FakeTest>): void {
|
|
692
|
+
for (const [, test] of tests) {
|
|
693
|
+
const classname = test.parent.title;
|
|
694
|
+
const attachments = test.attachments ?? [];
|
|
695
|
+
this.writeElement(
|
|
696
|
+
'testcase',
|
|
697
|
+
{
|
|
698
|
+
classname,
|
|
699
|
+
name: test.title,
|
|
700
|
+
time: getDuration(test),
|
|
701
|
+
},
|
|
702
|
+
() => {
|
|
703
|
+
if (test.state === 'failed') {
|
|
704
|
+
this.writeFailureOrError(test);
|
|
705
|
+
}
|
|
706
|
+
if (attachments.length > 0) {
|
|
707
|
+
this.writeElement('properties', {}, () => {
|
|
708
|
+
for (const absPath of attachments) {
|
|
709
|
+
this.writeElement('property', {
|
|
710
|
+
name: 'attachment',
|
|
711
|
+
value: relative(dirname(this.reportFile), absPath),
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
- [x] **Step 4: Run all tests**
|
|
723
|
+
|
|
724
|
+
```bash
|
|
725
|
+
yarn test tests/reporters/junit.test.ts
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
Expected: all tests pass.
|
|
729
|
+
|
|
730
|
+
- [x] **Step 5: Commit**
|
|
731
|
+
|
|
732
|
+
```bash
|
|
733
|
+
git add src/server/reporters/junit.ts tests/reporters/junit.test.ts
|
|
734
|
+
git commit -m "feat(junit): add screenshot attachment properties to testcase elements
|
|
735
|
+
|
|
736
|
+
Write <properties name=\"attachment\"> with paths relative to the
|
|
737
|
+
XML file for each entry in test.attachments.
|
|
738
|
+
|
|
739
|
+
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
---
|
|
743
|
+
|
|
744
|
+
## Task 5: Spec Attributes — `hostname` and Sequential `id`
|
|
745
|
+
|
|
746
|
+
**Files:**
|
|
747
|
+
|
|
748
|
+
- Modify: `src/server/reporters/junit.ts`
|
|
749
|
+
- Modify: `tests/reporters/junit.test.ts`
|
|
750
|
+
|
|
751
|
+
### Context
|
|
752
|
+
|
|
753
|
+
Jenkins JUnit plugin schema requires `hostname` on each `<testsuite>`. Sequential `id` (zero-indexed) is a standard attribute that lets CI tools process suites in order. We also remove the unused `// TODO Output attachments` comment.
|
|
754
|
+
|
|
755
|
+
- [x] **Step 1: Write failing tests for hostname and id attributes**
|
|
756
|
+
|
|
757
|
+
Add inside `describe('JUnitReporter', ...)`:
|
|
758
|
+
|
|
759
|
+
```typescript
|
|
760
|
+
describe('testsuite spec attributes', () => {
|
|
761
|
+
test('includes hostname attribute on testsuite', () => {
|
|
762
|
+
const out = track(tempXmlPath());
|
|
763
|
+
const xml = runReporter([makeFakeTest()], out);
|
|
764
|
+
expect(xml).toMatch(/hostname="[^"]+"/);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test('includes sequential id starting at 0 on each testsuite', () => {
|
|
768
|
+
const out = track(tempXmlPath());
|
|
769
|
+
const a = makeFakeTest({ storyTitle: 'Alpha', browserName: 'chrome' });
|
|
770
|
+
const b = makeFakeTest({ storyTitle: 'Beta', browserName: 'chrome' });
|
|
771
|
+
const xml = runReporter([a, b], out);
|
|
772
|
+
expect(xml).toContain('id="0"');
|
|
773
|
+
expect(xml).toContain('id="1"');
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
- [x] **Step 2: Run to confirm tests fail**
|
|
779
|
+
|
|
780
|
+
```bash
|
|
781
|
+
yarn test tests/reporters/junit.test.ts
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
Expected: FAIL — no `hostname` or `id` attributes currently.
|
|
785
|
+
|
|
786
|
+
- [x] **Step 3: Add `os` import**
|
|
787
|
+
|
|
788
|
+
At the top of `src/server/reporters/junit.ts`, add:
|
|
789
|
+
|
|
790
|
+
```typescript
|
|
791
|
+
import os from 'os';
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
- [x] **Step 4: Add `hostname` and `id` to the `testsuite` element in `onFinished`**
|
|
795
|
+
|
|
796
|
+
In `onFinished`, update the `suites.forEach` to include the index-based `id` and `os.hostname()`:
|
|
797
|
+
|
|
798
|
+
```typescript
|
|
799
|
+
suites.forEach(({ suiteName, browserName, tests, failures, errors, time, timestamp }, index) => {
|
|
800
|
+
this.writeElement(
|
|
801
|
+
'testsuite',
|
|
802
|
+
{
|
|
803
|
+
name: suiteName,
|
|
804
|
+
tests: tests.size,
|
|
805
|
+
failures,
|
|
806
|
+
errors,
|
|
807
|
+
time: executionTime(time),
|
|
808
|
+
hostname: os.hostname(),
|
|
809
|
+
id: index,
|
|
810
|
+
timestamp,
|
|
811
|
+
},
|
|
812
|
+
() => {
|
|
813
|
+
this.writeElement('properties', {}, () => {
|
|
814
|
+
this.writeElement('property', { name: 'browser', value: browserName });
|
|
815
|
+
});
|
|
816
|
+
this.writeTasks(tests);
|
|
817
|
+
},
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
- [x] **Step 5: Remove stale TODO comment**
|
|
823
|
+
|
|
824
|
+
In `src/server/reporters/junit.ts`, remove the line:
|
|
825
|
+
|
|
826
|
+
```typescript
|
|
827
|
+
// TODO Output attachments
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
- [x] **Step 6: Run all tests**
|
|
831
|
+
|
|
832
|
+
```bash
|
|
833
|
+
yarn test tests/reporters/junit.test.ts
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
Expected: all tests pass.
|
|
837
|
+
|
|
838
|
+
- [x] **Step 7: Type-check**
|
|
839
|
+
|
|
840
|
+
```bash
|
|
841
|
+
/Users/ki/Projects/creevey/creevey/node_modules/typescript/bin/tsc --noEmit
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
Expected: no new errors (pre-existing errors in `.creevey/`, `.storybook/`, and `docs/examples/` are unrelated — ignore them).
|
|
845
|
+
|
|
846
|
+
- [x] **Step 8: Commit**
|
|
847
|
+
|
|
848
|
+
```bash
|
|
849
|
+
git add src/server/reporters/junit.ts tests/reporters/junit.test.ts
|
|
850
|
+
git commit -m "feat(junit): add hostname and sequential id attributes to testsuite
|
|
851
|
+
|
|
852
|
+
Required by Jenkins JUnit plugin schema; useful for distributed CI runs.
|
|
853
|
+
|
|
854
|
+
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
---
|
|
858
|
+
|
|
859
|
+
## Task 6: Final Verification & Cleanup
|
|
860
|
+
|
|
861
|
+
**Files:**
|
|
862
|
+
|
|
863
|
+
- Possibly modify: `src/server/reporters/junit.ts` (cleanup only)
|
|
864
|
+
|
|
865
|
+
- [x] **Step 1: Run the full test suite**
|
|
866
|
+
|
|
867
|
+
```bash
|
|
868
|
+
yarn test
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
Expected: all existing tests pass; new reporter tests pass.
|
|
872
|
+
|
|
873
|
+
- [x] **Step 2: Verify the final state of `junit.ts` has no leftover TODOs from the plan**
|
|
874
|
+
|
|
875
|
+
Open `src/server/reporters/junit.ts` and confirm:
|
|
876
|
+
|
|
877
|
+
- No `// TODO Output attachments` comment
|
|
878
|
+
- `os` is imported
|
|
879
|
+
- `relative` is in the path imports
|
|
880
|
+
- `SuiteEntry` interface is defined above `JUnitReporter`
|
|
881
|
+
- `writeElement` has the 4-param signature
|
|
882
|
+
- `writeTasks` emits failure/error + attachment properties
|
|
883
|
+
- `writeFailureOrError` exists as a private method
|
|
884
|
+
- `onFinished` uses `index` in `forEach` for `id`
|
|
885
|
+
|
|
886
|
+
- [x] **Step 3: Final commit if any cleanup was needed**
|
|
887
|
+
|
|
888
|
+
```bash
|
|
889
|
+
git add src/server/reporters/junit.ts
|
|
890
|
+
git commit -m "chore(junit): final cleanup
|
|
891
|
+
|
|
892
|
+
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
---
|
|
896
|
+
|
|
897
|
+
## Self-Review
|
|
898
|
+
|
|
899
|
+
### Spec Coverage
|
|
900
|
+
|
|
901
|
+
| Spec requirement | Task |
|
|
902
|
+
| --------------------------------------------- | ------ |
|
|
903
|
+
| Suite keying by `suiteName/browserName` | Task 2 |
|
|
904
|
+
| `<property name="browser">` on each testsuite | Task 2 |
|
|
905
|
+
| `<failure>` with image error body | Task 3 |
|
|
906
|
+
| `<error>` for crashes (test.err, no images) | Task 3 |
|
|
907
|
+
| Separate `failures` and `errors` counts | Task 3 |
|
|
908
|
+
| Fallback `<failure message="Test failed">` | Task 3 |
|
|
909
|
+
| Screenshot `<properties name="attachment">` | Task 4 |
|
|
910
|
+
| Relative attachment paths | Task 4 |
|
|
911
|
+
| `hostname` on `<testsuite>` | Task 5 |
|
|
912
|
+
| Sequential `id` on `<testsuite>` | Task 5 |
|
|
913
|
+
| `writeElement` text content extension | Task 1 |
|
|
914
|
+
|
|
915
|
+
All spec requirements are covered. ✓
|
|
916
|
+
|
|
917
|
+
### Type Consistency
|
|
918
|
+
|
|
919
|
+
- `SuiteEntry` defined in Task 2, used consistently through Tasks 2–5.
|
|
920
|
+
- `writeElement` 4-param signature defined in Task 1, used in Task 3 (`writeFailureOrError`).
|
|
921
|
+
- `relative` import added in Task 4, used in `writeTasks`.
|
|
922
|
+
- `os` import added in Task 5, used in `onFinished`.
|
|
923
|
+
- `errors` field added to suite entry in Task 3, carried through `onFinished` stats.
|
|
924
|
+
|
|
925
|
+
### Placeholder Scan
|
|
926
|
+
|
|
927
|
+
No TBD, TODO, or placeholder content. All code blocks are complete. ✓
|