From 27f9bd066a560addb00af9b2758afa3c5afa66ba Mon Sep 17 00:00:00 2001 From: Tom Lauwaerts Date: Thu, 13 Aug 2026 12:14:16 +0200 Subject: [PATCH 1/2] Fix Ink duplicate-key --- src/manage/Uploader.ts | 4 +++- src/reporter/ink/ActiveFailures.tsx | 4 ++-- src/testbeds/Emulator.ts | 5 ++++- src/testbeds/Platform.ts | 24 +++++++++++++++++--- tests/unit/interface.test.ts | 28 +++++++++++++++++++++++- tests/unit/reporter.test.ts | 34 +++++++++++++++++++++++++++++ tsconfig.tests.json | 2 +- 7 files changed, 92 insertions(+), 9 deletions(-) diff --git a/src/manage/Uploader.ts b/src/manage/Uploader.ts index 6889416..f6f6b81 100644 --- a/src/manage/Uploader.ts +++ b/src/manage/Uploader.ts @@ -84,8 +84,9 @@ export class EmulatorConnector extends Uploader { private connectSocket(program: string, listener?: (chunk: any) => void): Promise { const that = this; - return new Promise(function (resolve, _reject) { + return new Promise(function (resolve, reject) { const client = new net.Socket(); + client.once('error', reject); client.connect(that.port, () => { that.emit(UploaderEvents.connected); if (listener !== undefined) { @@ -148,6 +149,7 @@ export class EmulatorUploader extends Uploader { if (data.includes('Listening')) { const client = new net.Socket(); + client.once('error', reject); client.connect(that.port, () => { that.emit(UploaderEvents.connected); if (listener !== undefined) { diff --git a/src/reporter/ink/ActiveFailures.tsx b/src/reporter/ink/ActiveFailures.tsx index 77642d5..2a4da1b 100644 --- a/src/reporter/ink/ActiveFailures.tsx +++ b/src/reporter/ink/ActiveFailures.tsx @@ -39,8 +39,8 @@ export function ActiveFailures({snapshot}: Props) { {run.suiteTitle} ({scenarios.length}/{run.plannedScenarios ?? run.scenarios.length}) - {failureRows(scenarios).map(({scenario, step}) => ( - + {failureRows(scenarios).map(({scenario, step}, index) => ( + TEST {scenario.name} diff --git a/src/testbeds/Emulator.ts b/src/testbeds/Emulator.ts index 20275dc..810cb7f 100644 --- a/src/testbeds/Emulator.ts +++ b/src/testbeds/Emulator.ts @@ -45,15 +45,18 @@ export class DummyProxy extends Emulator { this.dummy.on('connection', (connection) => { this.supervisor = connection; + connection.on('error', (error: Error) => this.failPending(error)); connection.on('data', (data) => { this.connection.channel.write(data.toString()); }); this.emit(TestbedEvents.Ready); }); + this.dummy.on('error', (error: Error) => this.failPending(error)); this.dummy.listen(specification.dummy.port); } protected listen(): void { + this.listenForErrors(); this.connection.channel.on('data', (data: Buffer) => { if (this.waitingForMessages()) { this.messages.push(data.toString()); @@ -74,4 +77,4 @@ export class DummyProxy extends Emulator { private waitingForMessages(): boolean { return this.requests.length > 0; } -} \ No newline at end of file +} diff --git a/src/testbeds/Platform.ts b/src/testbeds/Platform.ts index 114389b..422c665 100644 --- a/src/testbeds/Platform.ts +++ b/src/testbeds/Platform.ts @@ -6,11 +6,12 @@ import {Connection} from '../bridge/Connection'; import {SourceMap} from '../sourcemap/SourceMap'; type PromiseResolver = (value: R | PromiseLike) => void; +type PromiseRejector = (reason?: unknown) => void; export abstract class Platform extends EventEmitter implements Testbed { abstract connection: Connection; - protected requests: [Request, PromiseResolver][]; + protected requests: [Request, PromiseResolver, PromiseRejector][]; protected messages: MessageQueue; @@ -32,6 +33,11 @@ export abstract class Platform extends EventEmitter implements Testbed { this.messages.push(data.toString()); this.process(); }); + this.listenForErrors(); + } + + protected listenForErrors(): void { + this.connection.channel.on('error', (error: Error) => this.failPending(error)); } // listen on duplex channel @@ -56,6 +62,13 @@ export abstract class Platform extends EventEmitter implements Testbed { } } + protected failPending(error: Error): void { + const pending = this.requests.splice(0); + for (const [, , reject] of pending) { + reject(error); + } + } + // search for oldest request matching message private search(message: string): number { let index: number = 0; @@ -84,10 +97,15 @@ export abstract class Platform extends EventEmitter implements Testbed { const message = `${request.type}${request.payload?.(map) ?? ''}\n`; this.emit(TestbedEvents.Send, message); return new Promise((resolve, reject) => { - this.requests.push([request, resolve]); + const pending: [Request, PromiseResolver, PromiseRejector] = [request, resolve, reject]; + this.requests.push(pending); this.connection.channel.write(message, (err: Error | null | undefined) => { if (err !== null && err !== undefined) { - reject(err); + const index = this.requests.indexOf(pending); + if (index !== -1) { + this.requests.splice(index, 1); + reject(err); + } } }); }); diff --git a/tests/unit/interface.test.ts b/tests/unit/interface.test.ts index ec6aa6b..e590df7 100644 --- a/tests/unit/interface.test.ts +++ b/tests/unit/interface.test.ts @@ -1,4 +1,20 @@ import test from 'ava'; +import {PassThrough} from 'node:stream'; +import {SubProcess} from '../../src/bridge/SubProcess'; +import {Message} from '../../src/messaging/Message'; +import {SourceMap} from '../../src/sourcemap/SourceMap'; +import {Platform} from '../../src/testbeds/Platform'; + +class TestPlatform extends Platform { + readonly name = 'test'; + connection: SubProcess; + + constructor(channel: PassThrough) { + super(); + this.connection = new SubProcess(channel); + this.listen(); + } +} // file is currently excluded from tests @@ -6,6 +22,17 @@ test('[warduino] start emulator', t => { t.pass(); }); +test('[platform] rejects outstanding requests when the connection closes with an error', async t => { + const channel = new PassThrough(); + const platform = new TestPlatform(channel); + const expected = new Error('read ECONNRESET'); + const request = platform.sendRequest(new SourceMap.Mapping(), Message.run); + + channel.emit('error', expected); + + t.is(await t.throwsAsync(request), expected); +}); + test('[warduino] start oop testbed', t => { t.pass(); }); @@ -45,4 +72,3 @@ test('[dummy] log file create', t => { test('[dummy] log file correct', t => { t.pass(); }); - diff --git a/tests/unit/reporter.test.ts b/tests/unit/reporter.test.ts index 2ddc230..429f878 100644 --- a/tests/unit/reporter.test.ts +++ b/tests/unit/reporter.test.ts @@ -240,6 +240,40 @@ test('Ink App shows compact active failures before progress at normal verbosity' t.true(frame.indexOf('TEST failed-scenario ยท failed-step') < frame.indexOf('Progress')); }); +test('Ink App assigns unique keys to identical active failures', t => { + const suite = suiteResult('active-suite'); + const firstScenario = new ScenarioResult(scenario('duplicate-scenario')); + const secondScenario = new ScenarioResult(scenario('duplicate-scenario')); + const firstStep = new StepOutcome(step('duplicate-step')).update(Outcome.failed); + const secondStep = new StepOutcome(step('duplicate-step')).update(Outcome.failed); + firstScenario.add(firstStep); + secondScenario.add(secondStep); + + const state = new ReporterState(); + state.start(); + state.suiteStarted({...run('active', suite), plannedScenarios: 2}); + state.scenarioStarted('active', firstScenario); + state.stepFinished('active', firstScenario, firstStep); + state.scenarioFinished('active', firstScenario); + state.scenarioStarted('active', secondScenario); + state.stepFinished('active', secondScenario, secondStep); + + const originalError = console.error; + const errors: unknown[][] = []; + console.error = (...args: unknown[]) => errors.push(args); + try { + render(React.createElement(App, { + snapshot: state.snapshot(), + archive: 'suite.log', + verbosity: Verbosity.normal + })); + } finally { + console.error = originalError; + } + + t.false(errors.some(([message]) => `${message}`.includes('same key'))); +}); + test('Suite views show RUN while active and terminal outcomes after completion', t => { const cases: Array<[Outcome, string]> = [ [Outcome.succeeded, 'PASS'], diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 0a30929..917a7f0 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -9,7 +9,7 @@ "noEmit": false, "declaration": true, "declarationDir": "dist/types", - "incremental": true, + "incremental": false, "resolveJsonModule": true } } From e277a217c0afe0818f978f8f8c609c4a003b72f8 Mon Sep 17 00:00:00 2001 From: Tom Lauwaerts Date: Thu, 13 Aug 2026 13:27:55 +0200 Subject: [PATCH 2/2] Fix lint errors --- src/testbeds/Platform.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/testbeds/Platform.ts b/src/testbeds/Platform.ts index 422c665..846126a 100644 --- a/src/testbeds/Platform.ts +++ b/src/testbeds/Platform.ts @@ -11,7 +11,7 @@ type PromiseRejector = (reason?: unknown) => void; export abstract class Platform extends EventEmitter implements Testbed { abstract connection: Connection; - protected requests: [Request, PromiseResolver, PromiseRejector][]; + protected requests: [Request, PromiseResolver, PromiseRejector][]; protected messages: MessageQueue; @@ -97,7 +97,8 @@ export abstract class Platform extends EventEmitter implements Testbed { const message = `${request.type}${request.payload?.(map) ?? ''}\n`; this.emit(TestbedEvents.Send, message); return new Promise((resolve, reject) => { - const pending: [Request, PromiseResolver, PromiseRejector] = [request, resolve, reject]; + const resolver: PromiseResolver = value => resolve(value as R); + const pending: [Request, PromiseResolver, PromiseRejector] = [request as Request, resolver, reject]; this.requests.push(pending); this.connection.channel.write(message, (err: Error | null | undefined) => { if (err !== null && err !== undefined) {