Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build-tests/heft-swc-test/config/heft.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// TODO: Add comments
"phasesByName": {
"build": {
"cleanFiles": [{ "includeGlobs": ["dist", "lib", "lib-esnext", "lib-es5", "lib-umd", "temp"] }],
"cleanFiles": [{ "includeGlobs": ["dist", "lib-commonjs", "lib-esm", "lib-es5", "temp"] }],

"tasksByName": {
"typescript": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/terminal",
"comment": "Update API contract for `SplitterTransform` to support adding and removing destinations after creation.",
"type": "minor"
}
],
"packageName": "@rushstack/terminal"
}
6 changes: 4 additions & 2 deletions common/reviews/api/terminal.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export interface IProblemCollectorOptions extends ITerminalWritableOptions {

// @public
export interface ISplitterTransformOptions extends ITerminalWritableOptions {
destinations: TerminalWritable[];
destinations: Iterable<TerminalWritable>;
}

// @beta
Expand Down Expand Up @@ -323,12 +323,14 @@ export class RemoveColorsTextRewriter extends TextRewriter {
// @public
export class SplitterTransform extends TerminalWritable {
constructor(options: ISplitterTransformOptions);
addDestination(destination: TerminalWritable): void;
// (undocumented)
readonly destinations: ReadonlyArray<TerminalWritable>;
get destinations(): ReadonlySet<TerminalWritable>;
// (undocumented)
protected onClose(): void;
// (undocumented)
protected onWriteChunk(chunk: ITerminalChunk): void;
removeDestination(destination: TerminalWritable, close?: boolean): boolean;
}

// @beta
Expand Down
46 changes: 40 additions & 6 deletions libraries/terminal/src/SplitterTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import type { ITerminalChunk } from './ITerminalChunk';
*/
export interface ISplitterTransformOptions extends ITerminalWritableOptions {
/**
* Each input chunk will be passed to each destination in the array.
* Each input chunk will be passed to each destination in the iterable.
*/
destinations: TerminalWritable[];
destinations: Iterable<TerminalWritable>;
}

/**
Expand All @@ -29,15 +29,47 @@ export interface ISplitterTransformOptions extends ITerminalWritableOptions {
* @public
*/
export class SplitterTransform extends TerminalWritable {
public readonly destinations: ReadonlyArray<TerminalWritable>;
private readonly _destinations: Set<TerminalWritable>;

public constructor(options: ISplitterTransformOptions) {
super();
this.destinations = [...options.destinations];
this._destinations = new Set(options.destinations);
}

public get destinations(): ReadonlySet<TerminalWritable> {
return this._destinations;
}

/**
* Adds a destination to the set of destinations. Duplicates are ignored.
* Only new chunks received after the destination is added will be sent to it.
* @param destination - The destination to add.
*/
public addDestination(destination: TerminalWritable): void {
this._destinations.add(destination);
}

/**
* Removes a destination from the set of destinations. It will no longer receive chunks, and will be closed, unless
* `destination.preventAutoclose` is set to `true`.
* @param destination - The destination to remove.
* @param close - If `true` (default), the destination will be closed when removed, unless `destination.preventAutoclose` is set to `true`.
* @returns `true` if the destination was removed, `false` if it was not found.
* @remarks
* If the destination is not found, it will not be closed.
*/
public removeDestination(destination: TerminalWritable, close: boolean = true): boolean {
if (this._destinations.delete(destination)) {
if (close && !destination.preventAutoclose) {
destination.close();
}
return true;
}
return false;
}

protected onWriteChunk(chunk: ITerminalChunk): void {
for (const destination of this.destinations) {
for (const destination of this._destinations) {
destination.writeChunk(chunk);
}
}
Expand All @@ -46,7 +78,7 @@ export class SplitterTransform extends TerminalWritable {
const errors: Error[] = [];

// If an exception is thrown, try to ensure that the other destinations get closed properly
for (const destination of this.destinations) {
for (const destination of this._destinations) {
if (!destination.preventAutoclose) {
try {
destination.close();
Expand All @@ -56,6 +88,8 @@ export class SplitterTransform extends TerminalWritable {
}
}

this._destinations.clear();

if (errors.length > 0) {
throw errors[0];
}
Expand Down
157 changes: 157 additions & 0 deletions libraries/terminal/src/test/SplitterTransform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { SplitterTransform } from '../SplitterTransform';
import { MockWritable } from '../MockWritable';
import { TerminalChunkKind, type ITerminalChunk } from '../ITerminalChunk';

// Helper to create chunks succinctly
function c(text: string, kind: TerminalChunkKind = TerminalChunkKind.Stdout): ITerminalChunk {
return { text, kind };
}

describe(SplitterTransform.name, () => {
it('writes chunks to all initial destinations', () => {
const a: MockWritable = new MockWritable();
const b: MockWritable = new MockWritable();
const splitter: SplitterTransform = new SplitterTransform({ destinations: [a, b] });

splitter.writeChunk(c('one '));
splitter.writeChunk(c('two ', TerminalChunkKind.Stderr));
splitter.writeChunk(c('three'));
splitter.close();

// Both received identical chunk sequences
expect(a.chunks).toEqual(b.chunks);
// And each chunk reference should be the exact same object instance across destinations
expect(a.chunks[0]).toBe(b.chunks[0]);
expect(a.chunks[1]).toBe(b.chunks[1]);
expect(a.chunks[2]).toBe(b.chunks[2]);

expect(a.getFormattedChunks()).toMatchSnapshot();
});

describe(SplitterTransform.prototype.addDestination.name, () => {
it('only receives subsequent chunks', () => {
const a: MockWritable = new MockWritable();
const b: MockWritable = new MockWritable();
const late: MockWritable = new MockWritable();
const splitter: SplitterTransform = new SplitterTransform({ destinations: [a, b] });

splitter.writeChunk(c('early1 '));
splitter.writeChunk(c('early2 '));

splitter.addDestination(late);

splitter.writeChunk(c('late1 '));
splitter.writeChunk(c('late2'));
splitter.close();

expect(a.getAllOutput()).toBe('early1 early2 late1 late2');
expect(b.getAllOutput()).toBe('early1 early2 late1 late2');
expect(late.getAllOutput()).toBe('late1 late2');

expect({
a: a.getFormattedChunks(),
late: late.getFormattedChunks()
}).toMatchSnapshot();
});
});

describe(SplitterTransform.prototype.removeDestination.name, () => {
it('stops further writes and closes by default', () => {
class CloseTrackingWritable extends MockWritable {
public closed: boolean = false;
protected onClose(): void {
this.closed = true;
}
}

const a: CloseTrackingWritable = new CloseTrackingWritable();
const b: CloseTrackingWritable = new CloseTrackingWritable();
const splitter: SplitterTransform = new SplitterTransform({ destinations: [a, b] });

splitter.writeChunk(c('first '));
splitter.removeDestination(b); // default close=true

splitter.writeChunk(c('second'));
splitter.close();

// b should not have received 'second'
expect(a.getAllOutput()).toBe('first second');
expect(b.getAllOutput()).toBe('first ');
expect(b.closed).toBe(true);
expect(a.closed).toBe(true); // closed when splitter closed

expect({ a: a.getFormattedChunks(), b: b.getFormattedChunks() }).toMatchSnapshot();
});

it('with close=false keeps destination open', () => {
class CloseTrackingWritable extends MockWritable {
public closed: boolean = false;
protected onClose(): void {
this.closed = true;
}
}

const a: CloseTrackingWritable = new CloseTrackingWritable();
const b: CloseTrackingWritable = new CloseTrackingWritable();
const splitter: SplitterTransform = new SplitterTransform({ destinations: [a, b] });

splitter.writeChunk(c('first '));
splitter.removeDestination(b, false); // do not close

splitter.writeChunk(c('second'));
splitter.close();

expect(b.closed).toBe(false); // still open since not auto-closed by splitter and removed
// Manually close to avoid resource leak semantics
b.close();
expect(b.closed).toBe(true);

expect({ a: a.getFormattedChunks(), b: b.getFormattedChunks() }).toMatchSnapshot();
});

it('respects preventAutoclose', () => {
class CloseTrackingWritable extends MockWritable {
public closed: boolean = false;
public constructor(prevent: boolean) {
super({ preventAutoclose: prevent });
}
protected onClose(): void {
this.closed = true;
}
}

const a: CloseTrackingWritable = new CloseTrackingWritable(false);
const b: CloseTrackingWritable = new CloseTrackingWritable(true); // preventAutoclose
const splitter: SplitterTransform = new SplitterTransform({ destinations: [a, b] });

splitter.writeChunk(c('hello '));
splitter.removeDestination(b); // would normally close, but preventAutoclose=true
splitter.writeChunk(c('world'));
splitter.close();

expect(a.closed).toBe(true);
expect(b.closed).toBe(false); // not closed due to preventAutoclose
b.close();
expect(b.closed).toBe(true);

expect({ a: a.getFormattedChunks(), b: b.getFormattedChunks() }).toMatchSnapshot();
});

it('returns false when destination missing', () => {
const a: MockWritable = new MockWritable();
const b: MockWritable = new MockWritable();
const splitter: SplitterTransform = new SplitterTransform({ destinations: [a] });

const result: boolean = splitter.removeDestination(b); // not found
expect(result).toBe(false);

splitter.writeChunk(c('still works'));
splitter.close();

expect(a.getAllOutput()).toBe('still works');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`SplitterTransform addDestination only receives subsequent chunks 1`] = `
Object {
"a": Array [
Object {
"kind": "O",
"text": "early1 ",
},
Object {
"kind": "O",
"text": "early2 ",
},
Object {
"kind": "O",
"text": "late1 ",
},
Object {
"kind": "O",
"text": "late2",
},
],
"late": Array [
Object {
"kind": "O",
"text": "late1 ",
},
Object {
"kind": "O",
"text": "late2",
},
],
}
`;

exports[`SplitterTransform removeDestination respects preventAutoclose 1`] = `
Object {
"a": Array [
Object {
"kind": "O",
"text": "hello ",
},
Object {
"kind": "O",
"text": "world",
},
],
"b": Array [
Object {
"kind": "O",
"text": "hello ",
},
],
}
`;

exports[`SplitterTransform removeDestination stops further writes and closes by default 1`] = `
Object {
"a": Array [
Object {
"kind": "O",
"text": "first ",
},
Object {
"kind": "O",
"text": "second",
},
],
"b": Array [
Object {
"kind": "O",
"text": "first ",
},
],
}
`;

exports[`SplitterTransform removeDestination with close=false keeps destination open 1`] = `
Object {
"a": Array [
Object {
"kind": "O",
"text": "first ",
},
Object {
"kind": "O",
"text": "second",
},
],
"b": Array [
Object {
"kind": "O",
"text": "first ",
},
],
}
`;

exports[`SplitterTransform writes chunks to all initial destinations 1`] = `
Array [
Object {
"kind": "O",
"text": "one ",
},
Object {
"kind": "E",
"text": "two ",
},
Object {
"kind": "O",
"text": "three",
},
]
`;
Loading