eng: move selfhost test provider as a workspace extension (#208699)

Testing #208184, closes #207756
This commit is contained in:
Connor Peet
2024-03-28 09:08:23 -07:00
committed by GitHub
parent baa003c674
commit d42fad27b2
20 changed files with 2008 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/
import { IstanbulCoverageContext } from 'istanbul-to-vscode';
export const coverageContext = new IstanbulCoverageContext();

View File

@@ -0,0 +1,29 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/
/**
* Debounces the function call for an interval.
*/
export function debounce(duration: number, fn: () => void): (() => void) & { clear: () => void } {
let timeout: NodeJS.Timeout | void;
const debounced = () => {
if (timeout !== undefined) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
timeout = undefined;
fn();
}, duration);
};
debounced.clear = () => {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
};
return debounced;
}

View File

@@ -0,0 +1,314 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/
import { randomBytes } from 'crypto';
import { tmpdir } from 'os';
import * as path from 'path';
import * as vscode from 'vscode';
import { coverageContext } from './coverageProvider';
import { FailingDeepStrictEqualAssertFixer } from './failingDeepStrictEqualAssertFixer';
import { registerSnapshotUpdate } from './snapshot';
import { scanTestOutput } from './testOutputScanner';
import {
TestCase,
TestFile,
clearFileDiagnostics,
guessWorkspaceFolder,
itemData,
} from './testTree';
import { BrowserTestRunner, PlatformTestRunner, VSCodeTestRunner } from './vscodeTestRunner';
const TEST_FILE_PATTERN = 'src/vs/**/*.{test,integrationTest}.ts';
const getWorkspaceFolderForTestFile = (uri: vscode.Uri) =>
(uri.path.endsWith('.test.ts') || uri.path.endsWith('.integrationTest.ts')) &&
uri.path.includes('/src/vs/')
? vscode.workspace.getWorkspaceFolder(uri)
: undefined;
const browserArgs: [name: string, arg: string][] = [
['Chrome', 'chromium'],
['Firefox', 'firefox'],
['Webkit', 'webkit'],
];
type FileChangeEvent = { uri: vscode.Uri; removed: boolean };
export async function activate(context: vscode.ExtensionContext) {
const ctrl = vscode.tests.createTestController('selfhost-test-controller', 'VS Code Tests');
const fileChangedEmitter = new vscode.EventEmitter<FileChangeEvent>();
ctrl.resolveHandler = async test => {
if (!test) {
context.subscriptions.push(await startWatchingWorkspace(ctrl, fileChangedEmitter));
return;
}
const data = itemData.get(test);
if (data instanceof TestFile) {
// No need to watch this, updates will be triggered on file changes
// either by the text document or file watcher.
await data.updateFromDisk(ctrl, test);
}
};
const createRunHandler = (
runnerCtor: { new(folder: vscode.WorkspaceFolder): VSCodeTestRunner },
kind: vscode.TestRunProfileKind,
args: string[] = []
) => {
const doTestRun = async (
req: vscode.TestRunRequest,
cancellationToken: vscode.CancellationToken
) => {
const folder = await guessWorkspaceFolder();
if (!folder) {
return;
}
const runner = new runnerCtor(folder);
const map = await getPendingTestMap(ctrl, req.include ?? gatherTestItems(ctrl.items));
const task = ctrl.createTestRun(req);
for (const test of map.values()) {
task.enqueued(test);
}
let coverageDir: string | undefined;
let currentArgs = args;
if (kind === vscode.TestRunProfileKind.Coverage) {
coverageDir = path.join(tmpdir(), `vscode-test-coverage-${randomBytes(8).toString('hex')}`);
currentArgs = [
...currentArgs,
'--coverage',
'--coveragePath',
coverageDir,
'--coverageFormats',
'json',
'--coverageFormats',
'html',
];
}
return await scanTestOutput(
map,
task,
kind === vscode.TestRunProfileKind.Debug
? await runner.debug(currentArgs, req.include)
: await runner.run(currentArgs, req.include),
coverageDir,
cancellationToken
);
};
return async (req: vscode.TestRunRequest, cancellationToken: vscode.CancellationToken) => {
if (!req.continuous) {
return doTestRun(req, cancellationToken);
}
const queuedFiles = new Set<string>();
let debounced: NodeJS.Timeout | undefined;
const listener = fileChangedEmitter.event(({ uri, removed }) => {
clearTimeout(debounced);
if (req.include && !req.include.some(i => i.uri?.toString() === uri.toString())) {
return;
}
if (removed) {
queuedFiles.delete(uri.toString());
} else {
queuedFiles.add(uri.toString());
}
debounced = setTimeout(() => {
const include =
req.include?.filter(t => t.uri && queuedFiles.has(t.uri?.toString())) ??
[...queuedFiles]
.map(f => getOrCreateFile(ctrl, vscode.Uri.parse(f)))
.filter((f): f is vscode.TestItem => !!f);
queuedFiles.clear();
doTestRun(
new vscode.TestRunRequest(include, req.exclude, req.profile, true),
cancellationToken
);
}, 1000);
});
cancellationToken.onCancellationRequested(() => {
clearTimeout(debounced);
listener.dispose();
});
};
};
ctrl.createRunProfile(
'Run in Electron',
vscode.TestRunProfileKind.Run,
createRunHandler(PlatformTestRunner, vscode.TestRunProfileKind.Run),
true,
undefined,
true
);
ctrl.createRunProfile(
'Debug in Electron',
vscode.TestRunProfileKind.Debug,
createRunHandler(PlatformTestRunner, vscode.TestRunProfileKind.Debug),
true,
undefined,
true
);
const coverage = ctrl.createRunProfile(
'Coverage in Electron',
vscode.TestRunProfileKind.Coverage,
createRunHandler(PlatformTestRunner, vscode.TestRunProfileKind.Coverage),
true,
undefined,
true
);
coverage.loadDetailedCoverage = coverageContext.loadDetailedCoverage;
for (const [name, arg] of browserArgs) {
const cfg = ctrl.createRunProfile(
`Run in ${name}`,
vscode.TestRunProfileKind.Run,
createRunHandler(BrowserTestRunner, vscode.TestRunProfileKind.Run, [' --browser', arg]),
undefined,
undefined,
true
);
cfg.configureHandler = () => vscode.window.showInformationMessage(`Configuring ${name}`);
ctrl.createRunProfile(
`Debug in ${name}`,
vscode.TestRunProfileKind.Debug,
createRunHandler(BrowserTestRunner, vscode.TestRunProfileKind.Debug, [
'--browser',
arg,
'--debug-browser',
]),
undefined,
undefined,
true
);
}
function updateNodeForDocument(e: vscode.TextDocument) {
const node = getOrCreateFile(ctrl, e.uri);
const data = node && itemData.get(node);
if (data instanceof TestFile) {
data.updateFromContents(ctrl, e.getText(), node!);
}
}
for (const document of vscode.workspace.textDocuments) {
updateNodeForDocument(document);
}
context.subscriptions.push(
ctrl,
fileChangedEmitter.event(({ uri, removed }) => {
if (!removed) {
const node = getOrCreateFile(ctrl, uri);
if (node) {
ctrl.invalidateTestResults();
}
}
}),
vscode.workspace.onDidOpenTextDocument(updateNodeForDocument),
vscode.workspace.onDidChangeTextDocument(e => updateNodeForDocument(e.document)),
registerSnapshotUpdate(ctrl),
new FailingDeepStrictEqualAssertFixer()
);
}
export function deactivate() {
// no-op
}
function getOrCreateFile(
controller: vscode.TestController,
uri: vscode.Uri
): vscode.TestItem | undefined {
const folder = getWorkspaceFolderForTestFile(uri);
if (!folder) {
return undefined;
}
const data = new TestFile(uri, folder);
const existing = controller.items.get(data.getId());
if (existing) {
return existing;
}
const file = controller.createTestItem(data.getId(), data.getLabel(), uri);
controller.items.add(file);
file.canResolveChildren = true;
itemData.set(file, data);
return file;
}
function gatherTestItems(collection: vscode.TestItemCollection) {
const items: vscode.TestItem[] = [];
collection.forEach(item => items.push(item));
return items;
}
async function startWatchingWorkspace(
controller: vscode.TestController,
fileChangedEmitter: vscode.EventEmitter<FileChangeEvent>
) {
const workspaceFolder = await guessWorkspaceFolder();
if (!workspaceFolder) {
return new vscode.Disposable(() => undefined);
}
const pattern = new vscode.RelativePattern(workspaceFolder, TEST_FILE_PATTERN);
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
watcher.onDidCreate(uri => {
getOrCreateFile(controller, uri);
fileChangedEmitter.fire({ removed: false, uri });
});
watcher.onDidChange(uri => fileChangedEmitter.fire({ removed: false, uri }));
watcher.onDidDelete(uri => {
fileChangedEmitter.fire({ removed: true, uri });
clearFileDiagnostics(uri);
controller.items.delete(uri.toString());
});
for (const file of await vscode.workspace.findFiles(pattern)) {
getOrCreateFile(controller, file);
}
return watcher;
}
async function getPendingTestMap(ctrl: vscode.TestController, tests: Iterable<vscode.TestItem>) {
const queue = [tests];
const titleMap = new Map<string, vscode.TestItem>();
while (queue.length) {
for (const item of queue.pop()!) {
const data = itemData.get(item);
if (data instanceof TestFile) {
if (!data.hasBeenRead) {
await data.updateFromDisk(ctrl, item);
}
queue.push(gatherTestItems(item.children));
} else if (data instanceof TestCase) {
titleMap.set(data.fullName, item);
} else {
queue.push(gatherTestItems(item.children));
}
}
}
return titleMap;
}

View File

@@ -0,0 +1,255 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/
import * as ts from 'typescript';
import {
commands,
Disposable,
languages,
Position,
Range,
TestMessage,
TestResultSnapshot,
TestRunResult,
tests,
TextDocument,
Uri,
workspace,
WorkspaceEdit,
} from 'vscode';
import { memoizeLast } from './memoize';
import { getTestMessageMetadata } from './metadata';
const enum Constants {
FixCommandId = 'selfhost-test.fix-test',
}
export class FailingDeepStrictEqualAssertFixer {
private disposables: Disposable[] = [];
constructor() {
this.disposables.push(
commands.registerCommand(Constants.FixCommandId, async (uri: Uri, position: Position) => {
const document = await workspace.openTextDocument(uri);
const failingAssertion = detectFailingDeepStrictEqualAssertion(document, position);
if (!failingAssertion) {
return;
}
const expectedValueNode = failingAssertion.assertion.expectedValue;
if (!expectedValueNode) {
return;
}
const start = document.positionAt(expectedValueNode.getStart());
const end = document.positionAt(expectedValueNode.getEnd());
const edit = new WorkspaceEdit();
edit.replace(uri, new Range(start, end), formatJsonValue(failingAssertion.actualJSONValue));
await workspace.applyEdit(edit);
})
);
this.disposables.push(
languages.registerCodeActionsProvider('typescript', {
provideCodeActions: (document, range) => {
const failingAssertion = detectFailingDeepStrictEqualAssertion(document, range.start);
if (!failingAssertion) {
return undefined;
}
return [
{
title: 'Fix Expected Value',
command: Constants.FixCommandId,
arguments: [document.uri, range.start],
},
];
},
})
);
tests.testResults;
}
dispose() {
for (const d of this.disposables) {
d.dispose();
}
}
}
const identifierLikeRe = /^[$a-z_][a-z0-9_$]*$/i;
const tsPrinter = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const formatJsonValue = (value: unknown) => {
if (typeof value !== 'object') {
return JSON.stringify(value);
}
const src = ts.createSourceFile('', `(${JSON.stringify(value)})`, ts.ScriptTarget.ES5, true);
const outerExpression = src.statements[0] as ts.ExpressionStatement;
const parenExpression = outerExpression.expression as ts.ParenthesizedExpression;
const unquoted = ts.transform(parenExpression, [
context => (node: ts.Node) => {
const visitor = (node: ts.Node): ts.Node =>
ts.isPropertyAssignment(node) &&
ts.isStringLiteralLike(node.name) &&
identifierLikeRe.test(node.name.text)
? ts.factory.createPropertyAssignment(
ts.factory.createIdentifier(node.name.text),
ts.visitNode(node.initializer, visitor) as ts.Expression
)
: ts.isStringLiteralLike(node) && node.text === '[undefined]'
? ts.factory.createIdentifier('undefined')
: ts.visitEachChild(node, visitor, context);
return ts.visitNode(node, visitor);
},
]);
return tsPrinter.printNode(ts.EmitHint.Expression, unquoted.transformed[0], src);
};
/** Parses the source file, memoizing the last document so cursor moves are efficient */
const parseSourceFile = memoizeLast((text: string) =>
ts.createSourceFile('', text, ts.ScriptTarget.ES5, true)
);
const assertionFailureMessageRe = /^Expected values to be strictly (deep-)?equal:/;
/** Gets information about the failing assertion at the poisition, if any. */
function detectFailingDeepStrictEqualAssertion(
document: TextDocument,
position: Position
): { assertion: StrictEqualAssertion; actualJSONValue: unknown } | undefined {
const sf = parseSourceFile(document.getText());
const offset = document.offsetAt(position);
const assertion = StrictEqualAssertion.atPosition(sf, offset);
if (!assertion) {
return undefined;
}
const startLine = document.positionAt(assertion.offsetStart).line;
const messages = getAllTestStatusMessagesAt(document.uri, startLine);
const strictDeepEqualMessage = messages.find(m =>
assertionFailureMessageRe.test(typeof m.message === 'string' ? m.message : m.message.value)
);
if (!strictDeepEqualMessage) {
return undefined;
}
const metadata = getTestMessageMetadata(strictDeepEqualMessage);
if (!metadata) {
return undefined;
}
return {
assertion: assertion,
actualJSONValue: metadata.actualValue,
};
}
class StrictEqualAssertion {
/**
* Extracts the assertion at the current node, if it is one.
*/
public static fromNode(node: ts.Node): StrictEqualAssertion | undefined {
if (!ts.isCallExpression(node)) {
return undefined;
}
const expr = node.expression.getText();
if (expr !== 'assert.deepStrictEqual' && expr !== 'assert.strictEqual') {
return undefined;
}
return new StrictEqualAssertion(node);
}
/**
* Gets the equals assertion at the given offset in the file.
*/
public static atPosition(sf: ts.SourceFile, offset: number): StrictEqualAssertion | undefined {
let node = findNodeAt(sf, offset);
while (node.parent) {
const obj = StrictEqualAssertion.fromNode(node);
if (obj) {
return obj;
}
node = node.parent;
}
return undefined;
}
constructor(private readonly expression: ts.CallExpression) {}
/** Gets the expected value */
public get expectedValue(): ts.Expression | undefined {
return this.expression.arguments[1];
}
/** Gets the position of the assertion expression. */
public get offsetStart(): number {
return this.expression.getStart();
}
}
function findNodeAt(parent: ts.Node, offset: number): ts.Node {
for (const child of parent.getChildren()) {
if (child.getStart() <= offset && offset <= child.getEnd()) {
return findNodeAt(child, offset);
}
}
return parent;
}
function getAllTestStatusMessagesAt(uri: Uri, lineNumber: number): TestMessage[] {
if (tests.testResults.length === 0) {
return [];
}
const run = tests.testResults[0];
const snapshots = getTestResultsWithUri(run, uri);
const result: TestMessage[] = [];
for (const snapshot of snapshots) {
for (const m of snapshot.taskStates[0].messages) {
if (
m.location &&
m.location.range.start.line <= lineNumber &&
lineNumber <= m.location.range.end.line
) {
result.push(m);
}
}
}
return result;
}
function getTestResultsWithUri(testRun: TestRunResult, uri: Uri): TestResultSnapshot[] {
const results: TestResultSnapshot[] = [];
const walk = (r: TestResultSnapshot) => {
for (const c of r.children) {
walk(c);
}
if (r.uri?.toString() === uri.toString()) {
results.push(r);
}
};
for (const r of testRun.results) {
walk(r);
}
return results;
}

View File

@@ -0,0 +1,16 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/
export const memoizeLast = <A, T>(fn: (args: A) => T): ((args: A) => T) => {
let last: { arg: A; result: T } | undefined;
return arg => {
if (last && last.arg === arg) {
return last.result;
}
const result = fn(arg);
last = { arg, result };
return result;
};
};

View File

@@ -0,0 +1,60 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/
import { TestMessage } from 'vscode';
export interface TestMessageMetadata {
expectedValue: unknown;
actualValue: unknown;
}
const cache = new Array<{ id: string; metadata: TestMessageMetadata }>();
let id = 0;
function getId(): string {
return `msg:${id++}:`;
}
const regexp = /msg:\d+:/;
export function attachTestMessageMetadata(
message: TestMessage,
metadata: TestMessageMetadata
): void {
const existingMetadata = getTestMessageMetadata(message);
if (existingMetadata) {
Object.assign(existingMetadata, metadata);
return;
}
const id = getId();
if (typeof message.message === 'string') {
message.message = `${message.message}\n${id}`;
} else {
message.message.appendText(`\n${id}`);
}
cache.push({ id, metadata });
while (cache.length > 100) {
cache.shift();
}
}
export function getTestMessageMetadata(message: TestMessage): TestMessageMetadata | undefined {
let value: string;
if (typeof message.message === 'string') {
value = message.message;
} else {
value = message.message.value;
}
const result = regexp.exec(value);
if (!result) {
return undefined;
}
const id = result[0];
return cache.find(c => c.id === id)?.metadata;
}

View File

@@ -0,0 +1,22 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/
import { promises as fs } from 'fs';
import * as vscode from 'vscode';
export const snapshotComment = '\n\n// Snapshot file: ';
export const registerSnapshotUpdate = (ctrl: vscode.TestController) =>
vscode.commands.registerCommand('selfhost-test-provider.updateSnapshot', async args => {
const message: vscode.TestMessage = args.message;
const index = message.expectedOutput?.indexOf(snapshotComment);
if (!message.expectedOutput || !message.actualOutput || !index || index === -1) {
vscode.window.showErrorMessage('Could not find snapshot comment in message');
return;
}
const file = message.expectedOutput.slice(index + snapshotComment.length);
await fs.writeFile(file, message.actualOutput);
ctrl.invalidateTestResults(args.test);
});

View File

@@ -0,0 +1,67 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/
import * as ts from 'typescript';
import * as vscode from 'vscode';
import { TestCase, TestConstruct, TestSuite, VSCodeTest } from './testTree';
const suiteNames = new Set(['suite', 'flakySuite']);
export const enum Action {
Skip,
Recurse,
}
export const extractTestFromNode = (src: ts.SourceFile, node: ts.Node, parent: VSCodeTest) => {
if (!ts.isCallExpression(node)) {
return Action.Recurse;
}
let lhs = node.expression;
if (isSkipCall(lhs)) {
return Action.Skip;
}
if (isPropertyCall(lhs) && lhs.name.text === 'only') {
lhs = lhs.expression;
}
const name = node.arguments[0];
const func = node.arguments[1];
if (!name || !ts.isIdentifier(lhs) || !ts.isStringLiteralLike(name)) {
return Action.Recurse;
}
if (!func) {
return Action.Recurse;
}
const start = src.getLineAndCharacterOfPosition(name.pos);
const end = src.getLineAndCharacterOfPosition(func.end);
const range = new vscode.Range(
new vscode.Position(start.line, start.character),
new vscode.Position(end.line, end.character)
);
const cparent = parent instanceof TestConstruct ? parent : undefined;
if (lhs.escapedText === 'test') {
return new TestCase(name.text, range, cparent);
}
if (suiteNames.has(lhs.escapedText.toString())) {
return new TestSuite(name.text, range, cparent);
}
return Action.Recurse;
};
const isPropertyCall = (
lhs: ts.LeftHandSideExpression
): lhs is ts.PropertyAccessExpression & { expression: ts.Identifier; name: ts.Identifier } =>
ts.isPropertyAccessExpression(lhs) &&
ts.isIdentifier(lhs.expression) &&
ts.isIdentifier(lhs.name);
const isSkipCall = (lhs: ts.LeftHandSideExpression) =>
isPropertyCall(lhs) && suiteNames.has(lhs.expression.text) && lhs.name.text === 'skip';

View File

@@ -0,0 +1,60 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// DO NOT EDIT DIRECTLY: copied from src/vs/base/node/nodeStreams.ts
import { Transform } from 'stream';
/**
* A Transform stream that splits the input on the "splitter" substring.
* The resulting chunks will contain (and trail with) the splitter match.
* The last chunk when the stream ends will be emitted even if a splitter
* is not encountered.
*/
export class StreamSplitter extends Transform {
private buffer: Buffer | undefined;
private readonly splitter: number;
private readonly spitterLen: number;
constructor(splitter: string | number | Buffer) {
super();
if (typeof splitter === 'number') {
this.splitter = splitter;
this.spitterLen = 1;
} else {
throw new Error('not implemented here');
}
}
override _transform(chunk: Buffer, _encoding: string, callback: (error?: Error | null, data?: any) => void): void {
if (!this.buffer) {
this.buffer = chunk;
} else {
this.buffer = Buffer.concat([this.buffer, chunk]);
}
let offset = 0;
while (offset < this.buffer.length) {
const index = this.buffer.indexOf(this.splitter, offset);
if (index === -1) {
break;
}
this.push(this.buffer.slice(offset, index + this.spitterLen));
offset = index + this.spitterLen;
}
this.buffer = offset === this.buffer.length ? undefined : this.buffer.slice(offset);
callback();
}
override _flush(callback: (error?: Error | null, data?: any) => void): void {
if (this.buffer) {
this.push(this.buffer);
}
callback();
}
}

View File

@@ -0,0 +1,550 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/
import {
GREATEST_LOWER_BOUND,
LEAST_UPPER_BOUND,
originalPositionFor,
TraceMap,
} from '@jridgewell/trace-mapping';
import * as styles from 'ansi-styles';
import { ChildProcessWithoutNullStreams } from 'child_process';
import * as vscode from 'vscode';
import { coverageContext } from './coverageProvider';
import { attachTestMessageMetadata } from './metadata';
import { snapshotComment } from './snapshot';
import { getContentFromFilesystem } from './testTree';
import { StreamSplitter } from './streamSplitter';
export const enum MochaEvent {
Start = 'start',
TestStart = 'testStart',
Pass = 'pass',
Fail = 'fail',
End = 'end',
}
export interface IStartEvent {
total: number;
}
export interface ITestStartEvent {
title: string;
fullTitle: string;
file: string;
currentRetry: number;
speed: string;
}
export interface IPassEvent extends ITestStartEvent {
duration: number;
}
export interface IFailEvent extends IPassEvent {
err: string;
stack: string | null;
expected?: string;
actual?: string;
expectedJSON?: unknown;
actualJSON?: unknown;
snapshotPath?: string;
}
export interface IEndEvent {
suites: number;
tests: number;
passes: number;
pending: number;
failures: number;
start: string /* ISO date */;
end: string /* ISO date */;
}
export type MochaEventTuple =
| [MochaEvent.Start, IStartEvent]
| [MochaEvent.TestStart, ITestStartEvent]
| [MochaEvent.Pass, IPassEvent]
| [MochaEvent.Fail, IFailEvent]
| [MochaEvent.End, IEndEvent];
const LF = '\n'.charCodeAt(0);
export class TestOutputScanner implements vscode.Disposable {
protected mochaEventEmitter = new vscode.EventEmitter<MochaEventTuple>();
protected outputEventEmitter = new vscode.EventEmitter<string>();
protected onExitEmitter = new vscode.EventEmitter<string | undefined>();
/**
* Fired when a mocha event comes in.
*/
public readonly onMochaEvent = this.mochaEventEmitter.event;
/**
* Fired when other output from the process comes in.
*/
public readonly onOtherOutput = this.outputEventEmitter.event;
/**
* Fired when the process encounters an error, or exits.
*/
public readonly onRunnerExit = this.onExitEmitter.event;
constructor(private readonly process: ChildProcessWithoutNullStreams, private args?: string[]) {
process.stdout.pipe(new StreamSplitter(LF)).on('data', this.processData);
process.stderr.pipe(new StreamSplitter(LF)).on('data', this.processData);
process.on('error', e => this.onExitEmitter.fire(e.message));
process.on('exit', code =>
this.onExitEmitter.fire(code ? `Test process exited with code ${code}` : undefined)
);
}
/**
* @override
*/
public dispose() {
try {
this.process.kill();
} catch {
// ignored
}
}
protected readonly processData = (data: string) => {
if (this.args) {
this.outputEventEmitter.fire(`./scripts/test ${this.args.join(' ')}`);
this.args = undefined;
}
try {
const parsed = JSON.parse(data.trim()) as unknown;
if (parsed instanceof Array && parsed.length === 2 && typeof parsed[0] === 'string') {
this.mochaEventEmitter.fire(parsed as MochaEventTuple);
} else {
this.outputEventEmitter.fire(data);
}
} catch {
this.outputEventEmitter.fire(data);
}
};
}
type QueuedOutput = string | [string, vscode.Location | undefined, vscode.TestItem | undefined];
export async function scanTestOutput(
tests: Map<string, vscode.TestItem>,
task: vscode.TestRun,
scanner: TestOutputScanner,
coverageDir: string | undefined,
cancellation: vscode.CancellationToken
): Promise<void> {
const exitBlockers: Set<Promise<unknown>> = new Set();
const skippedTests = new Set(tests.values());
const store = new SourceMapStore();
let outputQueue = Promise.resolve();
const enqueueOutput = (fn: QueuedOutput | (() => Promise<QueuedOutput>)) => {
exitBlockers.delete(outputQueue);
outputQueue = outputQueue.finally(async () => {
const r = typeof fn === 'function' ? await fn() : fn;
typeof r === 'string' ? task.appendOutput(r) : task.appendOutput(...r);
});
exitBlockers.add(outputQueue);
return outputQueue;
};
const enqueueExitBlocker = <T>(prom: Promise<T>): Promise<T> => {
exitBlockers.add(prom);
prom.finally(() => exitBlockers.delete(prom));
return prom;
};
let lastTest: vscode.TestItem | undefined;
let ranAnyTest = false;
try {
if (cancellation.isCancellationRequested) {
return;
}
await new Promise<void>(resolve => {
cancellation.onCancellationRequested(() => {
resolve();
});
let currentTest: vscode.TestItem | undefined;
scanner.onRunnerExit(err => {
if (err) {
enqueueOutput(err + crlf);
}
resolve();
});
scanner.onOtherOutput(str => {
const match = spdlogRe.exec(str);
if (!match) {
enqueueOutput(str + crlf);
return;
}
const logLocation = store.getSourceLocation(match[2], Number(match[3]));
const logContents = replaceAllLocations(store, match[1]);
const test = currentTest;
enqueueOutput(() =>
Promise.all([logLocation, logContents]).then(([location, contents]) => [
contents + crlf,
location,
test,
])
);
});
scanner.onMochaEvent(evt => {
switch (evt[0]) {
case MochaEvent.Start:
break; // no-op
case MochaEvent.TestStart:
currentTest = tests.get(evt[1].fullTitle);
if (!currentTest) {
console.warn(`Could not find test ${evt[1].fullTitle}`);
return;
}
skippedTests.delete(currentTest);
task.started(currentTest);
ranAnyTest = true;
break;
case MochaEvent.Pass:
{
const title = evt[1].fullTitle;
const tcase = tests.get(title);
enqueueOutput(` ${styles.green.open}${styles.green.close} ${title}\r\n`);
if (tcase) {
lastTest = tcase;
task.passed(tcase, evt[1].duration);
tests.delete(title);
}
}
break;
case MochaEvent.Fail:
{
const {
err,
stack,
duration,
expected,
expectedJSON,
actual,
actualJSON,
snapshotPath,
fullTitle: id,
} = evt[1];
let tcase = tests.get(id);
// report failures on hook to the last-seen test, or first test if none run yet
if (!tcase && (id.includes('hook for') || id.includes('hook in'))) {
tcase = lastTest ?? tests.values().next().value;
}
enqueueOutput(`${styles.red.open} x ${id}${styles.red.close}\r\n`);
const rawErr = stack || err;
const locationsReplaced = replaceAllLocations(store, forceCRLF(rawErr));
if (rawErr) {
enqueueOutput(async () => [await locationsReplaced, undefined, tcase]);
}
if (!tcase) {
return;
}
tests.delete(id);
const hasDiff =
actual !== undefined &&
expected !== undefined &&
(expected !== '[undefined]' || actual !== '[undefined]');
const testFirstLine =
tcase.range &&
new vscode.Location(
tcase.uri!,
new vscode.Range(
tcase.range.start,
new vscode.Position(tcase.range.start.line, 100)
)
);
enqueueExitBlocker(
(async () => {
const location = await tryDeriveStackLocation(store, rawErr, tcase!);
let message: vscode.TestMessage;
if (hasDiff) {
message = new vscode.TestMessage(tryMakeMarkdown(err));
message.actualOutput = outputToString(actual);
message.expectedOutput = outputToString(expected);
if (snapshotPath) {
message.contextValue = 'isSelfhostSnapshotMessage';
message.expectedOutput += snapshotComment + snapshotPath;
}
attachTestMessageMetadata(message, {
expectedValue: expectedJSON,
actualValue: actualJSON,
});
} else {
message = new vscode.TestMessage(
stack ? await sourcemapStack(store, stack) : await locationsReplaced
);
}
message.location = location ?? testFirstLine;
task.failed(tcase!, message, duration);
})()
);
}
break;
case MochaEvent.End:
// no-op, we wait until the process exits to ensure coverage is written out
break;
}
});
});
await Promise.all([...exitBlockers]);
if (coverageDir) {
try {
await coverageContext.apply(task, coverageDir, {
mapFileUri: uri => store.getSourceFile(uri.toString()),
mapLocation: (uri, position) =>
store.getSourceLocation(uri.toString(), position.line, position.character),
});
} catch (e) {
const msg = `Error loading coverage:\n\n${e}\n`;
task.appendOutput(msg.replace(/\n/g, crlf));
}
}
// no tests? Possible crash, show output:
if (!ranAnyTest) {
await vscode.commands.executeCommand('testing.showMostRecentOutput');
}
} catch (e) {
task.appendOutput((e as Error).stack || (e as Error).message);
} finally {
scanner.dispose();
for (const test of skippedTests) {
task.skipped(test);
}
task.end();
}
}
const spdlogRe = /"(.+)", source: (file:\/\/\/.*?)+ \(([0-9]+)\)/;
const crlf = '\r\n';
const forceCRLF = (str: string) => str.replace(/(?<!\r)\n/gm, '\r\n');
const sourcemapStack = async (store: SourceMapStore, str: string) => {
locationRe.lastIndex = 0;
const replacements = await Promise.all(
[...str.matchAll(locationRe)].map(async match => {
const location = await deriveSourceLocation(store, match);
if (!location) {
return;
}
return {
from: match[0],
to: location?.uri.with({
fragment: `L${location.range.start.line + 1}:${location.range.start.character + 1}`,
}),
};
})
);
for (const replacement of replacements) {
if (replacement) {
str = str.replace(replacement.from, replacement.to.toString(true));
}
}
return str;
};
const outputToString = (output: unknown) =>
typeof output === 'object' ? JSON.stringify(output, null, 2) : String(output);
const tryMakeMarkdown = (message: string) => {
const lines = message.split('\n');
const start = lines.findIndex(l => l.includes('+ actual'));
if (start === -1) {
return message;
}
lines.splice(start, 1, '```diff');
lines.push('```');
return new vscode.MarkdownString(lines.join('\n'));
};
const inlineSourcemapRe = /^\/\/# sourceMappingURL=data:application\/json;base64,(.+)/m;
const sourceMapBiases = [GREATEST_LOWER_BOUND, LEAST_UPPER_BOUND] as const;
export class SourceMapStore {
private readonly cache = new Map</* file uri */ string, Promise<TraceMap | undefined>>();
async getSourceLocation(fileUri: string, line: number, col = 1) {
const sourceMap = await this.loadSourceMap(fileUri);
if (!sourceMap) {
return undefined;
}
for (const bias of sourceMapBiases) {
const position = originalPositionFor(sourceMap, { column: col, line: line + 1, bias });
if (position.line !== null && position.column !== null && position.source !== null) {
return new vscode.Location(
this.completeSourceMapUrl(sourceMap, position.source),
new vscode.Position(position.line - 1, position.column)
);
}
}
return undefined;
}
async getSourceFile(compiledUri: string) {
const sourceMap = await this.loadSourceMap(compiledUri);
if (!sourceMap) {
return undefined;
}
if (sourceMap.sources[0]) {
return this.completeSourceMapUrl(sourceMap, sourceMap.sources[0]);
}
for (const bias of sourceMapBiases) {
const position = originalPositionFor(sourceMap, { column: 0, line: 1, bias });
if (position.source !== null) {
return this.completeSourceMapUrl(sourceMap, position.source);
}
}
return undefined;
}
private completeSourceMapUrl(sm: TraceMap, source: string) {
if (sm.sourceRoot) {
try {
return vscode.Uri.parse(new URL(source, sm.sourceRoot).toString());
} catch {
// ignored
}
}
return vscode.Uri.parse(source);
}
private loadSourceMap(fileUri: string) {
const existing = this.cache.get(fileUri);
if (existing) {
return existing;
}
const promise = (async () => {
try {
const contents = await getContentFromFilesystem(vscode.Uri.parse(fileUri));
const sourcemapMatch = inlineSourcemapRe.exec(contents);
if (!sourcemapMatch) {
return;
}
const decoded = Buffer.from(sourcemapMatch[1], 'base64').toString();
return new TraceMap(decoded, fileUri);
} catch (e) {
console.warn(`Error parsing sourcemap for ${fileUri}: ${(e as Error).stack}`);
return;
}
})();
this.cache.set(fileUri, promise);
return promise;
}
}
const locationRe = /(file:\/{3}.+):([0-9]+):([0-9]+)/g;
async function replaceAllLocations(store: SourceMapStore, str: string) {
const output: (string | Promise<string>)[] = [];
let lastIndex = 0;
for (const match of str.matchAll(locationRe)) {
const locationPromise = deriveSourceLocation(store, match);
const startIndex = match.index || 0;
const endIndex = startIndex + match[0].length;
if (startIndex > lastIndex) {
output.push(str.substring(lastIndex, startIndex));
}
output.push(
locationPromise.then(location =>
location
? `${location.uri}:${location.range.start.line + 1}:${location.range.start.character + 1}`
: match[0]
)
);
lastIndex = endIndex;
}
// Preserve the remaining string after the last match
if (lastIndex < str.length) {
output.push(str.substring(lastIndex));
}
const values = await Promise.all(output);
return values.join('');
}
async function tryDeriveStackLocation(
store: SourceMapStore,
stack: string,
tcase: vscode.TestItem
) {
locationRe.lastIndex = 0;
return new Promise<vscode.Location | undefined>(resolve => {
const matches = [...stack.matchAll(locationRe)];
let todo = matches.length;
if (todo === 0) {
return resolve(undefined);
}
let best: undefined | { location: vscode.Location; i: number; score: number };
for (const [i, match] of matches.entries()) {
deriveSourceLocation(store, match)
.catch(() => undefined)
.then(location => {
if (location) {
let score = 0;
if (tcase.uri && tcase.uri.toString() === location.uri.toString()) {
score = 1;
if (tcase.range && tcase.range.contains(location?.range)) {
score = 2;
}
}
if (!best || score > best.score || (score === best.score && i < best.i)) {
best = { location, i, score };
}
}
if (!--todo) {
resolve(best?.location);
}
});
}
});
}
async function deriveSourceLocation(store: SourceMapStore, parts: RegExpMatchArray) {
const [, fileUri, line, col] = parts;
return store.getSourceLocation(fileUri, Number(line), Number(col));
}

View File

@@ -0,0 +1,175 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/
import { join, relative } from 'path';
import * as ts from 'typescript';
import { TextDecoder } from 'util';
import * as vscode from 'vscode';
import { Action, extractTestFromNode } from './sourceUtils';
const textDecoder = new TextDecoder('utf-8');
const diagnosticCollection = vscode.languages.createDiagnosticCollection('selfhostTestProvider');
type ContentGetter = (uri: vscode.Uri) => Promise<string>;
export const itemData = new WeakMap<vscode.TestItem, VSCodeTest>();
export const clearFileDiagnostics = (uri: vscode.Uri) => diagnosticCollection.delete(uri);
/**
* Tries to guess which workspace folder VS Code is in.
*/
export const guessWorkspaceFolder = async () => {
if (!vscode.workspace.workspaceFolders) {
return undefined;
}
if (vscode.workspace.workspaceFolders.length < 2) {
return vscode.workspace.workspaceFolders[0];
}
for (const folder of vscode.workspace.workspaceFolders) {
try {
await vscode.workspace.fs.stat(vscode.Uri.joinPath(folder.uri, 'src/vs/loader.js'));
return folder;
} catch {
// ignored
}
}
return undefined;
};
export const getContentFromFilesystem: ContentGetter = async uri => {
try {
const rawContent = await vscode.workspace.fs.readFile(uri);
return textDecoder.decode(rawContent);
} catch (e) {
console.warn(`Error providing tests for ${uri.fsPath}`, e);
return '';
}
};
export class TestFile {
public hasBeenRead = false;
constructor(
public readonly uri: vscode.Uri,
public readonly workspaceFolder: vscode.WorkspaceFolder
) {}
public getId() {
return this.uri.toString().toLowerCase();
}
public getLabel() {
return relative(join(this.workspaceFolder.uri.fsPath, 'src'), this.uri.fsPath);
}
public async updateFromDisk(controller: vscode.TestController, item: vscode.TestItem) {
try {
const content = await getContentFromFilesystem(item.uri!);
item.error = undefined;
this.updateFromContents(controller, content, item);
} catch (e) {
item.error = (e as Error).stack;
}
}
/**
* Refreshes all tests in this file, `sourceReader` provided by the root.
*/
public updateFromContents(
controller: vscode.TestController,
content: string,
file: vscode.TestItem
) {
try {
const diagnostics: vscode.Diagnostic[] = [];
const ast = ts.createSourceFile(
this.uri.path.split('/').pop()!,
content,
ts.ScriptTarget.ESNext,
false,
ts.ScriptKind.TS
);
const parents: { item: vscode.TestItem; children: vscode.TestItem[] }[] = [
{ item: file, children: [] },
];
const traverse = (node: ts.Node) => {
const parent = parents[parents.length - 1];
const childData = extractTestFromNode(ast, node, itemData.get(parent.item)!);
if (childData === Action.Skip) {
return;
}
if (childData === Action.Recurse) {
ts.forEachChild(node, traverse);
return;
}
const id = `${file.uri}/${childData.fullName}`.toLowerCase();
// Skip duplicated tests. They won't run correctly with the way
// mocha reports them, and will error if we try to insert them.
const existing = parent.children.find(c => c.id === id);
if (existing) {
const diagnostic = new vscode.Diagnostic(
childData.range,
'Duplicate tests cannot be run individually and will not be reported correctly by the test framework. Please rename them.',
vscode.DiagnosticSeverity.Warning
);
diagnostic.relatedInformation = [
new vscode.DiagnosticRelatedInformation(
new vscode.Location(existing.uri!, existing.range!),
'First declared here'
),
];
diagnostics.push(diagnostic);
return;
}
const item = controller.createTestItem(id, childData.name, file.uri);
itemData.set(item, childData);
item.range = childData.range;
parent.children.push(item);
if (childData instanceof TestSuite) {
parents.push({ item: item, children: [] });
ts.forEachChild(node, traverse);
item.children.replace(parents.pop()!.children);
}
};
ts.forEachChild(ast, traverse);
file.error = undefined;
file.children.replace(parents[0].children);
diagnosticCollection.set(this.uri, diagnostics.length ? diagnostics : undefined);
this.hasBeenRead = true;
} catch (e) {
file.error = String((e as Error).stack || (e as Error).message);
}
}
}
export abstract class TestConstruct {
public fullName: string;
constructor(
public readonly name: string,
public readonly range: vscode.Range,
parent?: TestConstruct
) {
this.fullName = parent ? `${parent.fullName} ${name}` : name;
}
}
export class TestSuite extends TestConstruct {}
export class TestCase extends TestConstruct {}
export type VSCodeTest = TestFile | TestSuite | TestCase;

View File

@@ -0,0 +1,306 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/
import { spawn } from 'child_process';
import { promises as fs } from 'fs';
import { AddressInfo, createServer } from 'net';
import * as path from 'path';
import * as vscode from 'vscode';
import { TestOutputScanner } from './testOutputScanner';
import { TestCase, TestFile, TestSuite, itemData } from './testTree';
/**
* From MDN
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
*/
const escapeRe = (s: string) => s.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
const TEST_ELECTRON_SCRIPT_PATH = 'test/unit/electron/index.js';
const TEST_BROWSER_SCRIPT_PATH = 'test/unit/browser/index.js';
const ATTACH_CONFIG_NAME = 'Attach to VS Code';
const DEBUG_TYPE = 'pwa-chrome';
export abstract class VSCodeTestRunner {
constructor(protected readonly repoLocation: vscode.WorkspaceFolder) { }
public async run(baseArgs: ReadonlyArray<string>, filter?: ReadonlyArray<vscode.TestItem>) {
const args = this.prepareArguments(baseArgs, filter);
const cp = spawn(await this.binaryPath(), args, {
cwd: this.repoLocation.uri.fsPath,
stdio: 'pipe',
env: this.getEnvironment(),
});
return new TestOutputScanner(cp, args);
}
public async debug(baseArgs: ReadonlyArray<string>, filter?: ReadonlyArray<vscode.TestItem>) {
const port = await this.findOpenPort();
const baseConfiguration = vscode.workspace
.getConfiguration('launch', this.repoLocation)
.get<vscode.DebugConfiguration[]>('configurations', [])
.find(c => c.name === ATTACH_CONFIG_NAME);
if (!baseConfiguration) {
throw new Error(`Could not find launch configuration ${ATTACH_CONFIG_NAME}`);
}
const server = this.createWaitServer();
const args = [
...this.prepareArguments(baseArgs, filter),
`--remote-debugging-port=${port}`,
// for breakpoint freeze: https://github.com/microsoft/vscode/issues/122225#issuecomment-885377304
'--js-flags="--regexp_interpret_all"',
// for general runtime freezes: https://github.com/microsoft/vscode/issues/127861#issuecomment-904144910
'--disable-features=CalculateNativeWinOcclusion',
'--timeout=0',
`--waitServer=${server.port}`,
];
const cp = spawn(await this.binaryPath(), args, {
cwd: this.repoLocation.uri.fsPath,
stdio: 'pipe',
env: this.getEnvironment(),
});
// Register a descriptor factory that signals the server when any
// breakpoint set requests on the debugee have been completed.
const factory = vscode.debug.registerDebugAdapterTrackerFactory(DEBUG_TYPE, {
createDebugAdapterTracker(session) {
if (!session.parentSession || session.parentSession !== rootSession) {
return;
}
let initRequestId: number | undefined;
return {
onDidSendMessage(message) {
if (message.type === 'response' && message.request_seq === initRequestId) {
server.ready();
}
},
onWillReceiveMessage(message) {
if (initRequestId !== undefined) {
return;
}
if (message.command === 'launch' || message.command === 'attach') {
initRequestId = message.seq;
}
},
};
},
});
vscode.debug.startDebugging(this.repoLocation, { ...baseConfiguration, port });
let exited = false;
let rootSession: vscode.DebugSession | undefined;
cp.once('exit', () => {
exited = true;
server.dispose();
listener.dispose();
factory.dispose();
if (rootSession) {
vscode.debug.stopDebugging(rootSession);
}
});
const listener = vscode.debug.onDidStartDebugSession(s => {
if (s.name === ATTACH_CONFIG_NAME && !rootSession) {
if (exited) {
vscode.debug.stopDebugging(rootSession);
} else {
rootSession = s;
}
}
});
return new TestOutputScanner(cp, args);
}
private findOpenPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.listen(0, () => {
const address = server.address() as AddressInfo;
const port = address.port;
server.close(() => {
resolve(port);
});
});
server.on('error', (error: Error) => {
reject(error);
});
});
}
protected getEnvironment(): NodeJS.ProcessEnv {
return {
...process.env,
ELECTRON_RUN_AS_NODE: undefined,
ELECTRON_ENABLE_LOGGING: '1',
};
}
private prepareArguments(
baseArgs: ReadonlyArray<string>,
filter?: ReadonlyArray<vscode.TestItem>
) {
const args = [...this.getDefaultArgs(), ...baseArgs, '--reporter', 'full-json-stream'];
if (!filter) {
return args;
}
const grepRe: string[] = [];
const runPaths = new Set<string>();
const addTestFileRunPath = (data: TestFile) =>
runPaths.add(
path.relative(data.workspaceFolder.uri.fsPath, data.uri.fsPath).replace(/\\/g, '/')
);
for (const test of filter) {
const data = itemData.get(test);
if (data instanceof TestCase || data instanceof TestSuite) {
grepRe.push(escapeRe(data.fullName) + (data instanceof TestCase ? '$' : ' '));
for (let p = test.parent; p; p = p.parent) {
const parentData = itemData.get(p);
if (parentData instanceof TestFile) {
addTestFileRunPath(parentData);
}
}
} else if (data instanceof TestFile) {
addTestFileRunPath(data);
}
}
if (grepRe.length) {
args.push('--grep', `/^(${grepRe.join('|')})/`);
}
if (runPaths.size) {
args.push(...[...runPaths].flatMap(p => ['--run', p]));
}
return args;
}
protected abstract getDefaultArgs(): string[];
protected abstract binaryPath(): Promise<string>;
protected async readProductJson() {
const projectJson = await fs.readFile(
path.join(this.repoLocation.uri.fsPath, 'product.json'),
'utf-8'
);
try {
return JSON.parse(projectJson);
} catch (e) {
throw new Error(`Error parsing product.json: ${(e as Error).message}`);
}
}
private createWaitServer() {
const onReady = new vscode.EventEmitter<void>();
let ready = false;
const server = createServer(socket => {
if (ready) {
socket.end();
} else {
onReady.event(() => socket.end());
}
});
server.listen(0);
return {
port: (server.address() as AddressInfo).port,
ready: () => {
ready = true;
onReady.fire();
},
dispose: () => {
server.close();
},
};
}
}
export class BrowserTestRunner extends VSCodeTestRunner {
/** @override */
protected binaryPath(): Promise<string> {
return Promise.resolve(process.execPath);
}
/** @override */
protected override getEnvironment() {
return {
...super.getEnvironment(),
ELECTRON_RUN_AS_NODE: '1',
};
}
/** @override */
protected getDefaultArgs() {
return [TEST_BROWSER_SCRIPT_PATH];
}
}
export class WindowsTestRunner extends VSCodeTestRunner {
/** @override */
protected async binaryPath() {
const { nameShort } = await this.readProductJson();
return path.join(this.repoLocation.uri.fsPath, `.build/electron/${nameShort}.exe`);
}
/** @override */
protected getDefaultArgs() {
return [TEST_ELECTRON_SCRIPT_PATH];
}
}
export class PosixTestRunner extends VSCodeTestRunner {
/** @override */
protected async binaryPath() {
const { applicationName } = await this.readProductJson();
return path.join(this.repoLocation.uri.fsPath, `.build/electron/${applicationName}`);
}
/** @override */
protected getDefaultArgs() {
return [TEST_ELECTRON_SCRIPT_PATH];
}
}
export class DarwinTestRunner extends PosixTestRunner {
/** @override */
protected override getDefaultArgs() {
return [
TEST_ELECTRON_SCRIPT_PATH,
'--no-sandbox',
'--disable-dev-shm-usage',
'--use-gl=swiftshader',
];
}
/** @override */
protected override async binaryPath() {
const { nameLong } = await this.readProductJson();
return path.join(
this.repoLocation.uri.fsPath,
`.build/electron/${nameLong}.app/Contents/MacOS/Electron`
);
}
}
export const PlatformTestRunner =
process.platform === 'win32'
? WindowsTestRunner
: process.platform === 'darwin'
? DarwinTestRunner
: PosixTestRunner;