diff --git a/.gitignore b/.gitignore index 7e53c34..1bde705 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ node_modules/ -lib/ .tui-test/ *.tgz t*.md diff --git a/index.js b/index.js old mode 100644 new mode 100755 diff --git a/lib/cli/index.d.ts b/lib/cli/index.d.ts new file mode 100644 index 0000000..533d3bf --- /dev/null +++ b/lib/cli/index.d.ts @@ -0,0 +1,2 @@ +import { Command } from "commander"; +export declare const program: Command; diff --git a/lib/cli/index.js b/lib/cli/index.js new file mode 100644 index 0000000..b42b388 --- /dev/null +++ b/lib/cli/index.js @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { Command } from "commander"; +import { getVersion } from "./version.js"; +import { executableName } from "../utils/constants.js"; +import { run } from "../runner/runner.js"; +import showTrace from "./show-trace.js"; +const action = async (testFilter, options) => { + const { updateSnapshot, trace } = options; + await run({ updateSnapshot: updateSnapshot ?? false, testFilter, trace }); +}; +export const program = new Command(); +program + .name(executableName) + .description(`a fast and precise end-to-end terminal testing framework + +Examples: +\`npx @microsoft/tui-test my.spec.ts\` +\`npx @microsoft/tui-test some.spec.ts:42\``) + .argument("[test-filter...]", "Pass an argument to filter test files. Each argument is treated as a regular expression. Matching is performed against the absolute file paths") + .option("-u, --updateSnapshot", `use this flag to re-record snapshots`) + .option("-t, --trace", `enable traces for test execution`) + .version(await getVersion(), "-v, --version", "output the current version") + .action(action) + .showHelpAfterError("(add --help for additional information)"); +program.addCommand(showTrace); diff --git a/lib/cli/show-trace.d.ts b/lib/cli/show-trace.d.ts new file mode 100644 index 0000000..b608064 --- /dev/null +++ b/lib/cli/show-trace.d.ts @@ -0,0 +1,3 @@ +import { Command } from "commander"; +declare const cmd: Command; +export default cmd; diff --git a/lib/cli/show-trace.js b/lib/cli/show-trace.js new file mode 100644 index 0000000..6845a00 --- /dev/null +++ b/lib/cli/show-trace.js @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { Command } from "commander"; +import { loadTrace } from "../trace/tracer.js"; +import { play } from "../trace/viewer.js"; +const action = async (traceFile) => { + const trace = await loadTrace(traceFile); + await play(trace); +}; +const cmd = new Command("show-trace") + .description(`view traces in the console`) + .argument("", "the trace to replay in the terminal"); +cmd.action(action); +export default cmd; diff --git a/lib/cli/version.d.ts b/lib/cli/version.d.ts new file mode 100644 index 0000000..e264d9c --- /dev/null +++ b/lib/cli/version.d.ts @@ -0,0 +1 @@ +export declare const getVersion: () => Promise; diff --git a/lib/cli/version.js b/lib/cli/version.js new file mode 100644 index 0000000..d80e915 --- /dev/null +++ b/lib/cli/version.js @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import url from "node:url"; +import path from "node:path"; +import fsAsync from "node:fs/promises"; +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +export const getVersion = async () => { + const packageJsonPath = path.join(__dirname, "..", "..", "package.json"); + const packageJson = await fsAsync.readFile(packageJsonPath, { + encoding: "utf-8", + }); + const packageJsonParsed = JSON.parse(packageJson); + return packageJsonParsed.version; +}; diff --git a/lib/config/config.d.ts b/lib/config/config.d.ts new file mode 100644 index 0000000..4b29a68 --- /dev/null +++ b/lib/config/config.d.ts @@ -0,0 +1,219 @@ +import { TestOptions } from "../test/option.js"; +export declare const loadConfig: () => Promise>; +export declare const getExpectTimeout: () => number; +export declare const getTimeout: () => number; +export declare const getRetries: () => number; +declare type TestProject = { + /** + * Project name is visible in the report and during test execution. + */ + name?: string; +}; +declare type WorkerOptions = { + /** + * Only the files matching one of these patterns are executed as test files. Matching is performed against the + * absolute file path. Strings are treated as glob patterns. + * + * By default, TUI Test looks for files matching the following glob pattern: `**\/*.@(spec|test).?(c|m)[jt]s?(x)`. + * This means JavaScript or TypeScript files with `".test"` or `".spec"` suffix, for example + * `bash.integration.spec.ts`. + * + * Use testConfig.testMatch to change this option for all projects. + */ + testMatch?: string; +}; +export declare type ProjectConfig = TestOptions & Required & TestProject; +export declare type TestConfig = { + /** + * Configuration for the `expect` assertion library. + * + * **Usage** + * + * ```js + * // tui-test.config.ts + * import { defineConfig } from '@microsoft/tui-test'; + * + * export default defineConfig({ + * expect: { + * timeout: 10000, + * }, + * }); + * ``` + * + */ + expect?: { + /** + * Default timeout for async expect matchers in milliseconds, defaults to 5000ms. + */ + timeout?: number; + }; + /** + * Timeout for each test in milliseconds. Defaults to 30 seconds. + * + * This is a base timeout for all tests. + * + * **Usage** + * + * ```js + * // tui-test.config.ts + * import { defineConfig } from '@microsoft/tui-test'; + * + * export default defineConfig({ + * timeout: 5 * 60 * 1000, + * }); + * ``` + * + */ + timeout?: number; + /** + * Only the files matching one of these patterns are executed as test files. Matching is performed against the + * absolute file path. Strings are treated as glob patterns. + * + * By default, TUI Test looks for files matching the following glob pattern: `**\/*.@(spec|test).?(c|m)[jt]s?(x)`. + * This means JavaScript or TypeScript files with `".test"` or `".spec"` suffix, for example + * `bash.integration.spec.ts`. + * + * Use testConfig.testMatch to change this option for all projects. + */ + testMatch?: string; + /** + * Maximum time in milliseconds the whole test suite can run. Zero timeout (default) disables this behavior. Useful on + * CI to prevent broken setup from running too long and wasting resources. + * + * **Usage** + * + * ```js + * // tui-test.config.ts + * import { defineConfig } from '@microsoft/tui-test'; + * + * export default defineConfig({ + * globalTimeout: process.env.CI ? 60 * 60 * 1000 : undefined, + * }); + * ``` + * + */ + globalTimeout?: number; + /** + * The maximum number of retry attempts given to failed tests. By default failing tests are not retried. + * + * **Usage** + * + * ```js + * // tui-test.config.ts + * import { defineConfig } from '@microsoft/tui-test'; + * + * export default defineConfig({ + * retries: 2, + * }); + * ``` + * + */ + retries?: number; + /** + * The list of builtin reporters to use. + * + * **Usage** + * + * ```js + * // tui-test.config.ts + * import { defineConfig } from '@microsoft/tui-test'; + * + * export default defineConfig({ + * reporter: 'list', + * }); + * ``` + * + */ + reporter?: "list"; + /** + * Options for all tests in this project + * + * ```js + * // tui-test.config.ts + * import { defineConfig, Shell } from '@microsoft/tui-test'; + * + * export default defineConfig({ + * projects: [ + * { + * name: 'bash', + * use: { + * shell: Shell.Bash, + * }, + * }, + * ], + * }); + * ``` + * + * Use testConfig.use to change this option for + * all projects. + */ + use?: TestOptions; + /** + * The number of workers to use. Defaults to 50% of the logical cpu cores. If + * there are less tests than requested workers, there will be 1 worker used per test. + * + * **Usage** + * + * ```js + * // tui-test.config.ts + * import { defineConfig } from '@microsoft/tui-test'; + * + * export default defineConfig({ + * workers: 4, + * }); + * ``` + * + */ + workers?: number; + /** + * Record each test run for replay. + * + * **Usage** + * + * ```js + * // tui-test.config.ts + * import { defineConfig } from '@microsoft/tui-test'; + * + * export default defineConfig({ + * trace: true, + * }); + * ``` + * + */ + trace?: boolean; + /** + * Folder to store the traces in. Defaults to `tui-traces` + * + * **Usage** + * + * ```js + * // tui-test.config.ts + * import { defineConfig } from '@microsoft/tui-test'; + * + * export default defineConfig({ + * traceFolder: "tui-traces", + * }); + * ``` + * + */ + traceFolder?: string; + /** + * TUI Test supports running multiple test projects at the same time. + * + * **Usage** + * + * ```js + * // tui-test.config.ts + * import { defineConfig, Shell } from '@microsoft/tui-test'; + * + * export default defineConfig({ + * projects: [ + * { name: 'bash', use: Shell.Bash } + * ] + * }); + * ``` + * + */ + projects?: ProjectConfig[]; +}; +export {}; diff --git a/lib/config/config.js b/lib/config/config.js new file mode 100644 index 0000000..8063fb4 --- /dev/null +++ b/lib/config/config.js @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import os from "node:os"; +import { defaultShell } from "../terminal/shell.js"; +import { cacheFolderName, configFileName } from "../utils/constants.js"; +const configPath = path.join(process.cwd(), cacheFolderName, configFileName); +let loadedConfig; +export const loadConfig = async () => { + const userConfig = !fs.existsSync(configPath) + ? {} + : (await import(`file://${configPath}`)).default ?? {}; + loadedConfig = { + testMatch: userConfig.testMatch ?? "**/*.@(spec|test).?(c|m)[jt]s?(x)", + expect: { + timeout: userConfig.timeout ?? 5000, + }, + globalTimeout: userConfig.globalTimeout ?? 0, + retries: userConfig.retries ?? 0, + projects: userConfig.projects ?? [], + timeout: userConfig.timeout ?? 30000, + reporter: userConfig.reporter ?? "list", + workers: Math.max(userConfig.workers ?? Math.max(Math.floor(os.cpus().length / 2), 1), 1), + trace: userConfig.trace ?? false, + traceFolder: userConfig.traceFolder ?? path.join(process.cwd(), "tui-traces"), + use: { + shell: userConfig.use?.shell ?? defaultShell, + rows: userConfig.use?.rows ?? 30, + columns: userConfig.use?.columns ?? 80, + program: userConfig.use?.program, + }, + }; + return loadedConfig; +}; +export const getExpectTimeout = () => loadedConfig?.expect.timeout ?? 5000; +export const getTimeout = () => loadedConfig?.timeout ?? 30000; +export const getRetries = () => loadedConfig?.retries ?? 0; diff --git a/lib/reporter/base.d.ts b/lib/reporter/base.d.ts new file mode 100644 index 0000000..c77ef15 --- /dev/null +++ b/lib/reporter/base.d.ts @@ -0,0 +1,24 @@ +import { TestCase, TestResult, TestStatus } from "../test/testcase.js"; +import { Shell } from "../terminal/shell.js"; +import { Suite } from "../test/suite.js"; +export type StaleSnapshotSummary = { + obsolete: number; + removed: number; +}; +export declare class BaseReporter { + protected currentTest: number; + protected isTTY: boolean; + constructor(); + private _plural; + start(testCount: number, shells: Shell[], maxWorkers: number): Promise; + startTest(test: TestCase, result: TestResult): void; + endTest(test: TestCase, result: TestResult): void; + end(rootSuite: Suite, staleSnapshotSummary: StaleSnapshotSummary): number; + private _generateSummary; + private _printSummary; + private _header; + private _retryHeader; + private _stdStreamHeader; + private _printFailures; + protected _resultColor(status: TestStatus): (str: string) => string; +} diff --git a/lib/reporter/base.js b/lib/reporter/base.js new file mode 100644 index 0000000..4f7342c --- /dev/null +++ b/lib/reporter/base.js @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import chalk from "chalk"; +import { loadShellVersions } from "./utils.js"; +export class BaseReporter { + currentTest; + isTTY; + constructor() { + this.currentTest = 0; + this.isTTY = process.stdout.isTTY; + } + _plural(text, count) { + return `${text}${count > 1 ? "s" : ""}`; + } + async start(testCount, shells, maxWorkers) { + const shellVersions = await loadShellVersions(shells); + const workers = Math.min(testCount, maxWorkers); + process.stdout.write(chalk.dim(`Running ${chalk.dim.reset(testCount)} ${this._plural("test", testCount)} using ${chalk.dim.reset(workers)} ${this._plural("worker", workers)} with the following shells:\n`)); + shellVersions.forEach(({ shell, version, target }) => { + process.stdout.write(shell + + chalk.dim(" version ") + + version + + chalk.dim(" running from ") + + target + + "\n"); + }); + process.stdout.write("\n" + (shellVersions.length == 0 ? "\n" : "")); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + startTest(test, result) { + if (this.isTTY) + this.currentTest += 1; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + endTest(test, result) { + if (!this.isTTY) + this.currentTest += 1; + } + end(rootSuite, staleSnapshotSummary) { + const summary = this._generateSummary(rootSuite, staleSnapshotSummary); + this._printFailures(summary); + this._printSummary(summary); + return summary.failuresToPrint.filter((failure) => failure.outcome() == "unexpected").length; + } + _generateSummary(rootSuite, staleSnapshotSummary) { + let didNotRun = 0; + let skipped = 0; + let expected = 0; + const unexpected = []; + const flaky = []; + const snapshots = { + written: 0, + updated: 0, + failed: 0, + passed: 0, + ...staleSnapshotSummary, + }; + rootSuite.allTests().forEach((test) => { + test.snapshots().forEach((snapshot) => { + switch (snapshot.result) { + case "passed": + snapshots.passed++; + break; + case "failed": + snapshots.failed++; + break; + case "written": + snapshots.written++; + break; + case "updated": + snapshots.updated++; + break; + } + }); + switch (test.outcome()) { + case "skipped": { + if (!test.results.length) { + ++didNotRun; + } + else { + ++skipped; + } + break; + } + case "expected": + ++expected; + break; + case "unexpected": + unexpected.push(test); + break; + case "flaky": + flaky.push(test); + break; + } + }); + const failuresToPrint = [...unexpected, ...flaky]; + return { + didNotRun, + skipped, + expected, + unexpected, + flaky, + failuresToPrint, + snapshots, + }; + } + _printSummary({ didNotRun, skipped, expected, unexpected, flaky, snapshots, }) { + const tokens = []; + if (unexpected.length) { + tokens.push(chalk.red(`${unexpected.length} failed`)); + } + if (flaky.length) { + tokens.push(chalk.yellow(`${flaky.length} flaky`)); + } + if (didNotRun > 0) { + tokens.push(chalk.yellow(`${didNotRun} did not run`)); + } + if (skipped > 0) { + tokens.push(chalk.yellow(`${skipped} skipped`)); + } + if (expected > 0) { + tokens.push(chalk.green(`${expected} passed`)); + } + const testTotal = unexpected.length + flaky.length + didNotRun + skipped + expected; + if (testTotal !== 0) { + process.stdout.write(`\n tests: ${tokens.join(", ")}, ${testTotal} total\n`); + } + const snapshotTokens = []; + if (snapshots.passed > 0) { + snapshotTokens.push(chalk.green(`${snapshots.passed} passed`)); + } + if (snapshots.failed > 0) { + snapshotTokens.push(chalk.red(`${snapshots.failed} failed`)); + } + if (snapshots.updated > 0) { + snapshotTokens.push(chalk.green(`${snapshots.updated} updated`)); + } + if (snapshots.written > 0) { + snapshotTokens.push(chalk.green(`${snapshots.written} written`)); + } + if (snapshots.obsolete > 0) { + snapshotTokens.push(chalk.yellow(`${snapshots.obsolete} obsolete`)); + } + if (snapshots.removed > 0) { + snapshotTokens.push(chalk.green(`${snapshots.removed} removed`)); + } + const snapshotTotal = snapshots.passed + + snapshots.failed + + snapshots.written + + snapshots.updated + + snapshots.removed + + snapshots.obsolete; + const snapshotPostfix = snapshots.failed > 0 || snapshots.obsolete > 0 + ? chalk.dim("(Inspect your code changes or use the `-u` flag to update them.)") + : ""; + if (snapshotTotal !== 0) { + process.stdout.write(` snapshots: ${snapshotTokens.join(", ")}, ${snapshotTotal} total ${snapshotPostfix}\n\n`); + } + else { + process.stdout.write("\n"); + } + } + _header(test, prefix) { + const line = (prefix ?? " ") + test.titlePath().join(" › ") + " "; + const stdoutWidth = process.stdout.columns - 4; + const padLength = stdoutWidth > 96 ? 96 : stdoutWidth; + return line.padEnd(padLength, "─"); + } + _retryHeader(retry) { + const stdoutWidth = process.stdout.columns - 4; + const padLength = stdoutWidth > 96 ? 96 : stdoutWidth; + return ` Retry #${retry} `.padEnd(padLength, "─"); + } + _stdStreamHeader(streamType) { + const streamTitle = streamType[0].toUpperCase() + streamType.slice(1); + const stdoutWidth = process.stdout.columns - 4; + const padLength = stdoutWidth > 36 ? 36 : stdoutWidth; + return ` ${streamTitle} `.padEnd(padLength, "─"); + } + _printFailures({ failuresToPrint }) { + const padContent = (error) => error + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + failuresToPrint.forEach((test, failureIdx) => { + test.results.forEach((result, resultIdx) => { + if (result.error == null) + return; + const errorHeader = resultIdx === 0 + ? this._resultColor(test.outcome())(this._header(test, ` ${failureIdx + 1}) `)) + : chalk.dim(this._retryHeader(resultIdx)); + let stdStreams = ""; + if (result.stdout?.trim()) { + stdStreams += + chalk.dim(this._stdStreamHeader("stdout")) + + "\n\n" + + padContent(result.stdout) + + "\n\n"; + } + if (result.stderr?.trim()) { + stdStreams += + chalk.dim(this._stdStreamHeader("stderr")) + + "\n\n" + + padContent(result.stderr) + + "\n\n"; + } + process.stdout.write("\n" + + errorHeader + + "\n\n" + + padContent(result.error) + + "\n\n" + + stdStreams); + }); + if (test.results.every((result) => result.error == null) && + test.outcome() === "unexpected") { + const errorHeader = this._resultColor(test.outcome())(this._header(test, ` ${failureIdx + 1}) `)); + process.stdout.write("\n" + + errorHeader + + "\n\n" + + padContent("Error: test passed when run with `test.fail`") + + "\n\n"); + } + }); + } + _resultColor(status) { + switch (status) { + case "expected": + return chalk.green; + case "unexpected": + return chalk.red; + case "flaky": + return chalk.yellow; + case "skipped": + return chalk.cyan; + case "pending": + return chalk.dim; + default: + return (str) => str; + } + } +} diff --git a/lib/reporter/list.d.ts b/lib/reporter/list.d.ts new file mode 100644 index 0000000..03648d4 --- /dev/null +++ b/lib/reporter/list.d.ts @@ -0,0 +1,18 @@ +import { Shell } from "../terminal/shell.js"; +import { TestCase, TestResult } from "../test/testcase.js"; +import { BaseReporter, StaleSnapshotSummary } from "./base.js"; +import { Suite } from "../test/suite.js"; +export declare class ListReporter extends BaseReporter { + private _testRows; + constructor(); + start(testCount: number, shells: Shell[], maxWorkers: number): Promise; + startTest(test: TestCase, result: TestResult): void; + endTest(test: TestCase, result: TestResult): void; + end(rootSuite: Suite, staleSnapshotSummary: StaleSnapshotSummary): number; + private _resultIcon; + private _linePrefix; + private _lineSuffix; + private _appendLine; + private _updateLine; + private _updateOrAppendLine; +} diff --git a/lib/reporter/list.js b/lib/reporter/list.js new file mode 100644 index 0000000..d1af625 --- /dev/null +++ b/lib/reporter/list.js @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import ms from "pretty-ms"; +import chalk from "chalk"; +import { fitToWidth, ansi } from "./utils.js"; +import { BaseReporter } from "./base.js"; +export class ListReporter extends BaseReporter { + _testRows; + constructor() { + super(); + this._testRows = {}; + } + async start(testCount, shells, maxWorkers) { + await super.start(testCount, shells, maxWorkers); + } + startTest(test, result) { + super.startTest(test, result); + if (!this.isTTY) { + return; + } + this._testRows[test.id] = this.currentTest; + const fullName = test.titlePath(); + const prefix = this._linePrefix(test, "start"); + const line = chalk.dim(fullName.join(" › ")) + this._lineSuffix(test, result); + this._appendLine(line, prefix); + } + endTest(test, result) { + super.endTest(test, result); + const fullName = test.titlePath(); + const prefix = this._linePrefix(test, "end"); + const line = this._resultColor(test.outcome())(fullName.join(" › ")) + + this._lineSuffix(test, result); + const row = this._testRows[test.id]; + this._updateOrAppendLine(row, line, prefix); + } + end(rootSuite, staleSnapshotSummary) { + return super.end(rootSuite, staleSnapshotSummary); + } + _resultIcon(status) { + const color = this._resultColor(status); + switch (status) { + case "flaky": + case "expected": + return color("✔"); + case "unexpected": + return color("✘"); + case "skipped": + case "pending": + return color("-"); + default: + return " "; + } + } + _linePrefix(test, testPoint) { + const row = this._testRows[test.id] ?? this.currentTest; + return testPoint === "end" + ? ` ${this._resultIcon(test.outcome())} ${chalk.dim(row)} ` + : ` ${this._resultIcon("pending")} ${chalk.dim(row)} `; + } + _lineSuffix(test, result) { + const timeTag = result.status === "pending" ? "" : chalk.dim(` (${ms(result.duration)})`); + const retryIdx = test.results.length - (result.status === "pending" ? 0 : 1); + const retryTag = retryIdx > 0 ? chalk.yellow(` (retry #${retryIdx})`) : ""; + return `${retryTag}${timeTag}`; + } + _appendLine(line, prefix) { + process.stdout.write(prefix + fitToWidth(line, process.stdout.columns, prefix) + "\n"); + } + _updateLine(row, line, prefix) { + const offset = -(row - this.currentTest - 1); + const updateAnsi = ansi.cursorPreviousLine.repeat(offset) + ansi.eraseCurrentLine; + const restoreAnsi = ansi.cursorNextLine.repeat(offset); + process.stdout.write(updateAnsi + + prefix + + fitToWidth(line, process.stdout.columns, prefix) + + restoreAnsi); + } + _updateOrAppendLine(row, line, prefix) { + if (this.isTTY) { + this._updateLine(row, line, prefix); + } + else { + this._appendLine(line, prefix); + } + } +} diff --git a/lib/reporter/utils.d.ts b/lib/reporter/utils.d.ts new file mode 100644 index 0000000..02ae828 --- /dev/null +++ b/lib/reporter/utils.d.ts @@ -0,0 +1,13 @@ +import { Shell } from "../terminal/shell.js"; +export declare const loadShellVersions: (shell: Shell[]) => Promise<{ + shell: Shell; + version?: string | undefined; + target: string; +}[]>; +export declare function stripAnsiEscapes(str: string): string; +export declare function fitToWidth(line: string, width: number, prefix?: string): string; +export declare const ansi: { + eraseCurrentLine: string; + cursorPreviousLine: string; + cursorNextLine: string; +}; diff --git a/lib/reporter/utils.js b/lib/reporter/utils.js new file mode 100644 index 0000000..51bd539 --- /dev/null +++ b/lib/reporter/utils.js @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { promisify } from "node:util"; +import { exec } from "node:child_process"; +import { Shell, shellLaunch } from "../terminal/shell.js"; +const execAsync = promisify(exec); +const ansiRegex = new RegExp( +// eslint-disable-next-line no-control-regex +"([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))", "g"); +export const loadShellVersions = async (shell) => { + return Promise.all(shell.map(async (shell) => { + const { shellTarget: target } = await shellLaunch(shell); + let version; + try { + switch (shell) { + case Shell.Bash: { + const output = (await execAsync('echo "$BASH_VERSION"', { shell: target })).stdout; + if (output.trim().length != 0) { + version = output.trim(); + } + break; + } + case Shell.Powershell: + case Shell.WindowsPowershell: { + const output = (await execAsync("$PSVersionTable", { shell: target })).stdout; + const match = output.match(/PSVersion\s*([^\s]*)/)?.at(1); + if (match != null) { + version = match; + } + break; + } + case Shell.Cmd: { + const output = (await execAsync("ver", { shell: target })).stdout; + const match = output.match(/\[Version\s*(.*)\]/)?.at(1); + if (match != null) { + version = match; + } + break; + } + case Shell.Fish: { + const output = (await execAsync('echo "$version"', { shell: target })).stdout; + if (output.trim().length != 0) { + version = output.trim(); + } + break; + } + case Shell.Zsh: { + const output = (await execAsync('echo "$ZSH_VERSION"', { shell: target })).stdout; + if (output.trim().length != 0) { + version = output.trim(); + } + break; + } + case Shell.Xonsh: { + const output = (await execAsync(`${target} -m xonsh -V`)).stdout.trim(); + // eslint-disable-next-line no-useless-escape + const match = output.match(/^xonsh\/([0-9\.]*)$/)?.at(1); + if (match != null) { + version = match; + } + break; + } + } + } + catch { + /* empty */ + } + version = (version?.length ?? 0) > 0 ? version : undefined; + return { shell, target, version }; + })); +}; +export function stripAnsiEscapes(str) { + return str.replace(ansiRegex, ""); +} +export function fitToWidth(line, width, prefix) { + const prefixLength = prefix ? stripAnsiEscapes(prefix).length : 0; + width -= prefixLength; + if (line.length <= width) + return line; + // Even items are plain text, odd items are control sequences. + const parts = line.split(ansiRegex); + const taken = []; + for (let i = parts.length - 1; i >= 0; i--) { + if (i % 2) { + // Include all control sequences to preserve formatting. + taken.push(parts[i]); + } + else { + let part = parts[i].substring(parts[i].length - width); + if (part.length < parts[i].length && part.length > 0) { + // Add ellipsis if we are truncating. + part = "\u2026" + part.substring(1); + } + taken.push(part); + width -= part.length; + } + } + return taken.reverse().join(""); +} +export const ansi = { + eraseCurrentLine: "\x1b[2K\x1b[G", // clear line and move cursor to start + cursorPreviousLine: "\x1b[F", + cursorNextLine: "\x1b[E", +}; diff --git a/lib/runner/runner.d.ts b/lib/runner/runner.d.ts new file mode 100644 index 0000000..b27bcf6 --- /dev/null +++ b/lib/runner/runner.d.ts @@ -0,0 +1,15 @@ +import { Suite } from "../test/suite.js"; +import { TestCase } from "../test/testcase.js"; +declare global { + var suite: Suite; + var tests: { + [testId: string]: TestCase; + }; +} +type ExecutionOptions = { + updateSnapshot: boolean; + trace?: boolean; + testFilter?: string[]; +}; +export declare const run: (options: ExecutionOptions) => Promise; +export {}; diff --git a/lib/runner/runner.js b/lib/runner/runner.js new file mode 100644 index 0000000..87c0b14 --- /dev/null +++ b/lib/runner/runner.js @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import path from "node:path"; +import url from "node:url"; +import os from "node:os"; +import workerpool from "workerpool"; +import chalk from "chalk"; +import { getRootSuite } from "../test/suite.js"; +import { transformFiles } from "./transform.js"; +import { getRetries, getTimeout, loadConfig, } from "../config/config.js"; +import { runTestWorker } from "./worker.js"; +import { Shell, setupZshDotfiles } from "../terminal/shell.js"; +import { ListReporter } from "../reporter/list.js"; +import { cacheFolderName, executableName } from "../utils/constants.js"; +import { supportsColor } from "chalk"; +import { cleanSnapshot } from "../test/matchers/toMatchSnapshot.js"; +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const createWorkerPool = (maxWorkers) => { + return workerpool.pool(path.join(__dirname, "worker.js"), { + workerType: "process", + maxWorkers, + forkOpts: { + stdio: "inherit", + env: { + ...process.env, + ...(supportsColor ? { FORCE_COLOR: "1" } : {}), + }, + }, + emitStdStreams: true, + }); +}; +const runSuites = async (allSuites, filteredTestIds, reporter, options, config, pool) => { + const { updateSnapshot } = options; + const trace = options.trace ?? config.trace; + const tasks = []; + const suites = [...allSuites]; + while (suites.length != 0) { + const suite = suites.shift(); + if (!suite) + break; + tasks.push(...suite.tests.map(async (test) => { + if (!filteredTestIds.has(test.id)) { + return; + } + for (let i = 0; i < Math.max(0, getRetries()) + 1; i++) { + const testResult = await runTestWorker(test, test.sourcePath(), { timeout: getTimeout(), updateSnapshot }, trace, pool, reporter, i, config.traceFolder); + test.results.push(testResult); + reporter.endTest(test, testResult); + if (testResult.status == "skipped" || + testResult.status == test.expectedStatus) + break; + } + })); + suites.push(...suite.suites); + } + return Promise.all(tasks); +}; +const checkNodeVersion = () => { + const nodeVersion = process.versions.node; + const nodeMajorVersion = nodeVersion.split(".")[0].trim(); + if (nodeMajorVersion != "16" && + nodeMajorVersion != "18" && + nodeMajorVersion != "20" && + nodeMajorVersion != "22" && + nodeMajorVersion != "24") { + console.warn(chalk.yellow(`Warning: ${executableName} works best when using a supported node versions (which ${nodeVersion} is not).\n`)); + } +}; +const checkShellSupport = (shells) => { + let platform = ""; + let badShells = []; + if (os.platform() === "darwin") { + badShells = shells.filter((shell) => shell == Shell.Cmd || + shell == Shell.Powershell || + shell == Shell.WindowsPowershell); + platform = "macOS"; + } + else if (os.platform() == "win32") { + badShells = shells.filter((shell) => shell == Shell.Zsh || shell == Shell.Fish); + platform = "Windows"; + } + else if (os.platform() == "linux") { + badShells = shells.filter((shell) => shell == Shell.Cmd || shell == Shell.WindowsPowershell); + platform = "Linux"; + } + if (badShells.length != 0) { + console.warn(chalk.yellow(`Warning: ${executableName} does not support the following shells on ${platform} ${badShells.join(", ")}`)); + } +}; +const cleanSnapshots = async (tests, { updateSnapshot }) => { + const snapshotFiles = tests.reduce((snapshots, test) => { + const source = test.filePath() ?? ""; + const snapshotNames = test.snapshots().map((snapshot) => snapshot.name); + snapshots.set(source, [...(snapshots.get(source) ?? []), ...snapshotNames]); + return snapshots; + }, new Map()); + let unusedSnapshots = 0; + for (const snapshotFile of snapshotFiles.keys()) { + unusedSnapshots += await cleanSnapshot(snapshotFile, new Set(snapshotFiles.get(snapshotFile)), updateSnapshot); + } + return unusedSnapshots; +}; +export const run = async (options) => { + checkNodeVersion(); + await transformFiles(); + const config = await loadConfig(); + const rootSuite = await getRootSuite(config); + const reporter = new ListReporter(); + const pool = createWorkerPool(config.workers); + const suites = [rootSuite]; + while (suites.length != 0) { + const importSuite = suites.shift(); + if (importSuite?.type === "file") { + const transformedSuitePath = path.join(process.cwd(), cacheFolderName, importSuite.title); + const parsedSuitePath = path.parse(transformedSuitePath); + const extension = parsedSuitePath.ext.startsWith(".m") ? ".mjs" : ".js"; + const importablePath = `file://${path.join(parsedSuitePath.dir, `${parsedSuitePath.name}${extension}`)}`; + importSuite.source = importablePath; + globalThis.suite = importSuite; + await import(importablePath); + } + suites.push(...(importSuite?.suites ?? [])); + } + let allTests = rootSuite.allTests(); + // refine with only annotations + if (allTests.find((test) => test.annotations.includes("only"))) { + allTests = allTests.filter((test) => test.annotations.includes("only")); + } + // refine with test filters + if (options.testFilter != null && options.testFilter.length > 0) { + try { + const patterns = options.testFilter.map((filter) => new RegExp(filter.replaceAll("\\", "\\\\"))); + allTests = allTests.filter((test) => { + const testPath = path.resolve(test.filePath() ?? ""); + return patterns.find((pattern) => pattern.test(testPath)) != null; + }); + } + catch { + console.error("Error: invalid test filter supplied. Test filters must be valid regular expressions"); + process.exit(1); + } + } + const shells = Array.from(new Set(allTests + .map((t) => t.suite.options?.shell) + .filter((s) => s != null))); + checkShellSupport(shells); + if (shells.includes(Shell.Zsh)) { + await setupZshDotfiles(); + } + await reporter.start(allTests.length, shells, config.workers); + if (config.globalTimeout > 0) { + setTimeout(() => { + console.error(`Error: global timeout (${config.globalTimeout} ms) exceeded`); + process.exit(1); + }, config.globalTimeout); + } + await runSuites(rootSuite.suites, new Set(allTests.map((test) => test.id)), reporter, options, config, pool); + try { + await pool.terminate(true); + } + catch { + /* empty */ + } + const staleSnapshots = await cleanSnapshots(allTests, options); + const failures = reporter.end(rootSuite, { + obsolete: options.updateSnapshot ? 0 : staleSnapshots, + removed: options.updateSnapshot ? staleSnapshots : 0, + }); + process.exit(failures); +}; diff --git a/lib/runner/transform.d.ts b/lib/runner/transform.d.ts new file mode 100644 index 0000000..50d532e --- /dev/null +++ b/lib/runner/transform.d.ts @@ -0,0 +1 @@ +export declare const transformFiles: () => Promise; diff --git a/lib/runner/transform.js b/lib/runner/transform.js new file mode 100644 index 0000000..27c15d8 --- /dev/null +++ b/lib/runner/transform.js @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import path from "node:path"; +import fsAsync from "node:fs/promises"; +import fs from "node:fs"; +import crypto from "node:crypto"; +import readline from "node:readline"; +import process from "node:process"; +import swc from "@swc/core"; +import { cacheFolderName } from "../utils/constants.js"; +const typescriptPattern = /^\.[mc]?ts[x]?$/; +const javascriptPattern = /^\.[mc]?js[x]?$/; +const transformFile = async (source, sourceContent, sourceHash, destination) => { + const fileExtension = path.extname(source); + const fileType = typescriptPattern.test(fileExtension) + ? "typescript" + : javascriptPattern.test(fileExtension) + ? "javascript" + : undefined; + if (fileType == null) { + throw new Error(""); + } + const result = await swc.transform(sourceContent, { + filename: path.basename(source), + swcrc: false, + configFile: false, + sourceMaps: true, + module: { + type: "es6", + }, + jsc: { + parser: fileType == "typescript" + ? { + syntax: "typescript", + tsx: fileExtension == ".tsx", + } + : { + syntax: "ecmascript", + jsx: fileExtension == ".jsx", + }, + }, + }); + const mapDestination = path.resolve(destination + ".map"); + const destinationFilename = path.basename(destination); + const mapHeader = result.map != null + ? `\n//# sourceMappingURL=${destinationFilename + ".map"}` + : ""; + const hashHeader = `//# hash=${sourceHash}`; + const code = `${hashHeader}${mapHeader}\n\n${result.code}`; + await fsAsync.writeFile(destination, code); + if (result.map != null) { + const map = JSON.parse(result.map); + await fsAsync.writeFile(mapDestination, JSON.stringify({ ...map, file: destinationFilename, sources: [source] })); + } +}; +const copyFilesToCache = async (directory, destination) => { + const directoryItems = await fsAsync.readdir(directory, { + withFileTypes: true, + }); + await Promise.all(directoryItems.map(async (directoryItem) => { + if (directoryItem.isDirectory() && directoryItem.name.startsWith(".")) { + return; + } + const resolvedPath = path.resolve(directory, directoryItem.name); + const destinationPath = path.join(destination, directoryItem.name); + if (directoryItem.isDirectory() && directoryItem.name == "node_modules") { + return; + } + else if (directoryItem.isDirectory()) { + if (!fs.existsSync(destinationPath)) { + await fsAsync.mkdir(destinationPath); + } + await copyFilesToCache(resolvedPath, destinationPath); + } + else if (directoryItem.isFile() || directoryItem.isSymbolicLink()) { + const fileExtension = path.extname(directoryItem.name); + if (typescriptPattern.test(fileExtension) || + javascriptPattern.test(fileExtension)) { + const content = await fsAsync.readFile(resolvedPath); + const fileHash = crypto + .createHash("md5") + .update(content) + .digest("hex"); + const newExtension = fileExtension.startsWith(".m") ? ".mjs" : ".js"; + const transformedPath = path.join(destination, `${path.parse(directoryItem.name).name}${newExtension}`); + if (fs.existsSync(transformedPath)) { + const reader = readline.createInterface({ + input: fs.createReadStream(transformedPath), + crlfDelay: Infinity, + }); + const line = await new Promise((resolve) => { + reader.on("line", (line) => { + reader.close(); + resolve(line); + }); + }); + const existingHash = line.match(/\/\/#\s+hash=(.*)/)?.at(1); + if (existingHash === fileHash) { + return; + } + } + await transformFile(resolvedPath, content.toString(), fileHash, transformedPath); + } + else if (fileExtension == ".snap") { + await fsAsync.copyFile(resolvedPath, `${destinationPath}.cjs`); + } + else { + await fsAsync.copyFile(resolvedPath, destinationPath); + } + } + })); +}; +export const transformFiles = async () => { + process.setSourceMapsEnabled(true); + if (!fs.existsSync(cacheFolderName)) { + await fsAsync.mkdir(cacheFolderName, { recursive: true }); + } + else { + // TODO: remove cache clearing add smart file cleanup between runs + for (const file of await fsAsync.readdir(cacheFolderName)) { + await fsAsync.rm(path.join(cacheFolderName, file), { recursive: true }); + } + } + await copyFilesToCache(process.cwd(), cacheFolderName); +}; diff --git a/lib/runner/worker.d.ts b/lib/runner/worker.d.ts new file mode 100644 index 0000000..dbba207 --- /dev/null +++ b/lib/runner/worker.d.ts @@ -0,0 +1,17 @@ +import workerpool from "workerpool"; +import { Snapshot, TestCase, TestStatus } from "../test/testcase.js"; +import { BaseReporter } from "../reporter/base.js"; +type WorkerResult = { + error?: string; + stdout?: string; + stderr?: string; + status: TestStatus; + duration: number; + snapshots: Snapshot[]; +}; +type WorkerExecutionOptions = { + timeout: number; + updateSnapshot: boolean; +}; +export declare function runTestWorker(test: TestCase, importPath: string, { timeout, updateSnapshot }: WorkerExecutionOptions, trace: boolean, pool: workerpool.Pool, reporter: BaseReporter, attempt: number, traceFolder: string): Promise; +export {}; diff --git a/lib/runner/worker.js b/lib/runner/worker.js new file mode 100644 index 0000000..a000aca --- /dev/null +++ b/lib/runner/worker.js @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import process from "node:process"; +import { EventEmitter } from "node:events"; +import workerpool from "workerpool"; +import { Suite } from "../test/suite.js"; +import { spawn } from "../terminal/term.js"; +import { defaultShell } from "../terminal/shell.js"; +import { expect } from "../test/test.js"; +import { poll } from "../utils/poll.js"; +import { flushSnapshotExecutionCache } from "../test/matchers/toMatchSnapshot.js"; +import { saveTrace } from "../trace/tracer.js"; +const importSet = new Set(); +const runTest = async (testId, testSuite, updateSnapshot, trace, tracePoints, importPath) => { + process.setSourceMapsEnabled(true); + globalThis.suite = testSuite; + globalThis.tests = globalThis.tests ?? {}; + globalThis.__expectState = { updateSnapshot }; + if (!importSet.has(importPath)) { + await import(importPath); + importSet.add(importPath); + } + const test = globalThis.tests[testId]; + const { shell, rows, columns, env, program } = test.suite.options ?? {}; + const traceEmitter = new EventEmitter(); + traceEmitter.on("data", (data, time) => tracePoints.push({ data, time })); + traceEmitter.on("size", (rows, cols) => tracePoints.push({ rows, cols })); + const terminal = await spawn({ + shell: shell ?? defaultShell, + rows: rows ?? 30, + cols: columns ?? 80, + env, + program, + }, trace, traceEmitter); + const allTests = Object.values(globalThis.tests); + const testPath = test.filePath(); + const testSignature = test.titlePath().slice(1).join(" › "); + const signatureIdenticalTests = allTests.filter((t) => t.filePath() === testPath && + t.titlePath().slice(1).join(" › ") === testSignature); + const signatureIdx = signatureIdenticalTests.findIndex((t) => t.id == test.id); + const currentConcurrentTestName = () => `${testSignature} | ${signatureIdx + 1}`; + expect.setState({ + ...expect.getState(), + testPath, + currentTestName: test.title, + currentConcurrentTestName, + }); + // wait on the shell to be ready with the prompt + if (program == null) { + await poll(() => { + const view = terminal + .getViewableBuffer() + .map((row) => row.join("")) + .join("\n"); + return view.includes("> "); + }, 50, 5000); + } + await Promise.resolve(test.testFunction({ terminal })); + try { + terminal.kill(); + } + catch { + // terminal can pre-terminate if program is provided + } +}; +export async function runTestWorker(test, importPath, { timeout, updateSnapshot }, trace, pool, reporter, attempt, traceFolder) { + const snapshots = []; + if (test.expectedStatus === "skipped") { + reporter.startTest(test, { + status: "pending", + duration: 0, + snapshots, + }); + return { + status: "skipped", + duration: 0, + snapshots, + }; + } + return new Promise((resolve) => { + let startTime = Date.now(); + let reportStarted = false; + let stdout = ""; + let stderr = ""; + try { + const poolPromise = pool.exec("testWorker", [ + test.id, + getMockSuite(test), + updateSnapshot, + trace, + importPath, + attempt, + traceFolder, + ], { + on: (payload) => { + if (payload.stdout) { + stdout += payload.stdout; + } + if (payload.stderr) { + stderr += payload.stderr; + } + if (payload.errorMessage) { + resolve({ + status: "unexpected", + error: payload.errorMessage, + duration: payload.duration, + snapshots, + stdout, + stderr, + }); + } + else if (payload.startTime && !reportStarted) { + reporter.startTest(test, { + status: "pending", + duration: 0, + snapshots, + }); + reportStarted = true; + startTime = payload.startTime; + } + else if (payload.snapshotResult) { + snapshots.push({ + name: payload.snapshotName, + result: payload.snapshotResult, + }); + } + }, + }); + if (timeout > 0) { + poolPromise.timeout(timeout); + } + poolPromise.then(() => { + if (!reportStarted) { + reporter.startTest(test, { + status: "pending", + duration: 0, + snapshots, + }); + } + resolve({ + status: "expected", + duration: Date.now() - startTime, + snapshots, + stdout, + stderr, + }); + }); + } + catch (e) { + const duration = startTime != null ? Date.now() - startTime : -1; + if (!reportStarted) { + reporter.startTest(test, { + status: "pending", + duration: 0, + snapshots, + }); + } + if (typeof e === "string") { + resolve({ + status: "unexpected", + error: e, + duration, + snapshots, + stdout, + stderr, + }); + } + else if (e instanceof workerpool.Promise.TimeoutError) { + resolve({ + status: "unexpected", + error: `Error: worker was terminated as the timeout (${timeout} ms) as exceeded`, + duration, + snapshots, + stdout, + stderr, + }); + } + else if (e instanceof Error) { + resolve({ + status: "unexpected", + error: e.stack ?? e.message, + duration, + snapshots, + stdout, + stderr, + }); + } + } + }); +} +const getMockSuite = (test) => { + let testSuite = test.suite; + const newSuites = []; + while (testSuite != null) { + if (testSuite.type !== "describe") { + newSuites.push(new Suite(testSuite.title, testSuite.type, testSuite.options)); + } + testSuite = testSuite.parentSuite; + } + for (let i = 0; i < newSuites.length - 1; i++) { + newSuites[i].parentSuite = newSuites[i + 1]; + } + return newSuites[0]; +}; +const testWorker = async (testId, testSuite, updateSnapshot, trace, importPath, attempt, traceFolder) => { + flushSnapshotExecutionCache(); + const startTime = Date.now(); + const tracePoints = [{ data: "", time: startTime }]; + workerpool.workerEmit({ + startTime, + }); + try { + await runTest(testId, testSuite, updateSnapshot, trace, tracePoints, importPath); + } + catch (e) { + let errorMessage; + if (typeof e == "string") { + errorMessage = e; + } + else if (e instanceof Error) { + errorMessage = e.stack ?? e.message; + } + if (errorMessage) { + const duration = Date.now() - startTime; + workerpool.workerEmit({ + errorMessage, + duration, + }); + } + } + if (trace) { + await saveTrace(tracePoints, testId, attempt, traceFolder); + } +}; +if (!workerpool.isMainThread) { + workerpool.worker({ + testWorker: testWorker, + }); +} diff --git a/lib/terminal/ansi.d.ts b/lib/terminal/ansi.d.ts new file mode 100644 index 0000000..afa2bc3 --- /dev/null +++ b/lib/terminal/ansi.d.ts @@ -0,0 +1,16 @@ +declare const _default: { + keyUp: string; + keyDown: string; + keyRight: string; + keyLeft: string; + ESC: string; + keyBackspace: string; + keyDelete: string; + keyCtrlC: string; + keyCtrlD: string; + saveScreen: string; + restoreScreen: string; + clearScreen: string; + cursorTo: (x: number, y: number) => string; +}; +export default _default; diff --git a/lib/terminal/ansi.js b/lib/terminal/ansi.js new file mode 100644 index 0000000..2dacf17 --- /dev/null +++ b/lib/terminal/ansi.js @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// source: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html +const ESC = "\u001B"; +const CSI = "\u001B["; +const SEP = ";"; +const keyUp = CSI + "A"; +const keyDown = CSI + "B"; +const keyRight = CSI + "C"; +const keyLeft = CSI + "D"; +const keyBackspace = "\u007F"; +const keyDelete = CSI + "3~"; +const keyCtrlC = String.fromCharCode(3); +const keyCtrlD = String.fromCharCode(4); +const saveScreen = CSI + "?47h"; +const restoreScreen = CSI + "?47l"; +const clearScreen = CSI + "2J"; +const cursorTo = (x, y) => { + return CSI + (y + 1) + SEP + (x + 1) + "H"; +}; +export default { + keyUp, + keyDown, + keyRight, + keyLeft, + ESC, + keyBackspace, + keyDelete, + keyCtrlC, + keyCtrlD, + saveScreen, + restoreScreen, + clearScreen, + cursorTo, +}; diff --git a/lib/terminal/locator.d.ts b/lib/terminal/locator.d.ts new file mode 100644 index 0000000..d65a87d --- /dev/null +++ b/lib/terminal/locator.d.ts @@ -0,0 +1,31 @@ +import { IBufferCell, Terminal as XTerminal } from "@xterm/headless"; +import { Terminal } from "./term.js"; +export type Cell = { + termCell: IBufferCell | undefined; + x: number; + y: number; +}; +export declare class Locator { + private readonly _text; + private readonly _term; + private readonly _xterm; + private readonly _full; + private readonly _strict; + private _cells; + constructor(_text: string | RegExp, _term: Terminal, _xterm: XTerminal, _full?: boolean | undefined, _strict?: boolean); + private static _getIndicesOf; + /** + * Gets a locator's search term. This is not supported for direct usage in + * testing and can break in future releases. + * + */ + searchTerm(): string | RegExp; + /** + * Resolves a locator to a specific set of cells. This is not supported for + * direct usage in testing and can break in future releases. + * + * @param timeout + * @param isNot + */ + resolve(timeout: number, isNot?: boolean): Promise; +} diff --git a/lib/terminal/locator.js b/lib/terminal/locator.js new file mode 100644 index 0000000..ba8b7a0 --- /dev/null +++ b/lib/terminal/locator.js @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import ms from "pretty-ms"; +import { poll } from "../utils/poll.js"; +import { strictModeErrorPrefix } from "../utils/constants.js"; +export class Locator { + _text; + _term; + _xterm; + _full; + _strict; + _cells; + constructor(_text, _term, _xterm, _full = undefined, _strict = false) { + this._text = _text; + this._term = _term; + this._xterm = _xterm; + this._full = _full; + this._strict = _strict; + } + static _getIndicesOf(substring, string) { + const indices = []; + let index = 0; + while ((index = string.indexOf(substring, index)) > -1) { + indices.push(index); + index += substring.length; + } + return indices; + } + /** + * Gets a locator's search term. This is not supported for direct usage in + * testing and can break in future releases. + * + */ + searchTerm() { + return this._text; + } + /** + * Resolves a locator to a specific set of cells. This is not supported for + * direct usage in testing and can break in future releases. + * + * @param timeout + * @param isNot + */ + async resolve(timeout, isNot = false) { + if (this._cells != null) + return this._cells; + const result = await poll(() => { + const buffer = this._full + ? this._term.getBuffer() + : this._term.getViewableBuffer(); + const block = buffer.map((bufferLine) => bufferLine.join("")).join(""); + let index = 0; + let length = 0; + if (typeof this._text === "string") { + const indices = Locator._getIndicesOf(this._text, block); + if (indices.length > 1 && this._strict) { + throw new Error(`${strictModeErrorPrefix}: getByText(${this._text.toString()}) resolved to ${indices.length} elements`); + } + if (indices.length == 0) { + return false; + } + index = indices[0]; + length = this._text.length; + } + else { + const matches = Array.from(block.matchAll(this._text)); + if (matches.length > 1 && this._strict) { + throw new Error(`${strictModeErrorPrefix}: getByText(${this._text.toString()}) resolved to ${matches.length} elements`); + } + if (matches.length == 0) { + return false; + } + index = matches[0].index; + length = matches[0].length; + } + const baseY = this._full ? 0 : this._xterm.buffer.active.baseY; + this._cells = []; + for (let y = 0; y < buffer.length; y++) { + for (let x = 0; x < buffer[y].length ?? 0; x++) { + const pos = x + y * buffer[y].length; + if (pos >= index && pos < index + length) { + this._cells.push({ + termCell: this._xterm.buffer.active + .getLine(baseY + y) + ?.getCell(x), + x, + y, + }); + } + } + } + return true; + }, 50, timeout, isNot); + if (!result && !isNot) { + throw new Error(`locator timeout: getByText(${this._text.toString()}) resolved to 0 elements after ${ms(timeout)}`); + } + if (!result && isNot) { + this._cells = undefined; + } + return this._cells; + } +} diff --git a/lib/terminal/shell.d.ts b/lib/terminal/shell.d.ts new file mode 100644 index 0000000..33c4581 --- /dev/null +++ b/lib/terminal/shell.d.ts @@ -0,0 +1,22 @@ +export declare enum Shell { + Bash = "bash", + WindowsPowershell = "powershell", + Powershell = "pwsh", + Cmd = "cmd", + Fish = "fish", + Zsh = "zsh", + Xonsh = "xonsh" +} +export declare const defaultShell: Shell; +export declare const userZdotdir: string; +export declare const zdotdir: string; +export declare const shellLaunch: (shell: Shell) => Promise<{ + shellTarget: string; + shellArgs: string[] | undefined; +}>; +export declare const shellEnv: (shell: Shell) => { + [x: string]: string | undefined; + TZ?: string | undefined; +}; +export declare const setupZshDotfiles: () => Promise; +export declare const getPythonPath: () => Promise; diff --git a/lib/terminal/shell.js b/lib/terminal/shell.js new file mode 100644 index 0000000..fde52ce --- /dev/null +++ b/lib/terminal/shell.js @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import os from "node:os"; +import fs from "node:fs"; +import path from "node:path"; +import url from "node:url"; +import which from "which"; +import fsAsync from "node:fs/promises"; +export var Shell; +(function (Shell) { + Shell["Bash"] = "bash"; + Shell["WindowsPowershell"] = "powershell"; + Shell["Powershell"] = "pwsh"; + Shell["Cmd"] = "cmd"; + Shell["Fish"] = "fish"; + Shell["Zsh"] = "zsh"; + Shell["Xonsh"] = "xonsh"; +})(Shell || (Shell = {})); +export const defaultShell = os.platform() == "win32" + ? Shell.Cmd + : os.platform() == "darwin" + ? Shell.Zsh + : Shell.Bash; +export const userZdotdir = process.env?.ZDOTDIR ?? os.homedir() ?? `~`; +export const zdotdir = path.join(os.tmpdir(), `tui-test-zsh`); +export const shellLaunch = async (shell) => { + const platform = os.platform(); + let shellTarget = shell == Shell.Bash && platform == "win32" + ? await gitBashPath() + : platform == "win32" + ? `${shell}.exe` + : shell; + const shellFolderPath = path.join(path.dirname(url.fileURLToPath(import.meta.url)), "..", "..", "shell"); + let shellArgs = undefined; + switch (shell) { + case Shell.Bash: + shellArgs = [ + "--init-file", + path.join(shellFolderPath, "shellIntegration.bash"), + ]; + break; + case Shell.WindowsPowershell: + shellArgs = [ + "-NoLogo", + "-noexit", + "-command", + `try { . "${path.join(shellFolderPath, "shellIntegration.ps1")}" } catch {}`, + ]; + break; + case Shell.Powershell: + shellArgs = [ + "-noexit", + "-command", + `. "${path.join(shellFolderPath, "shellIntegration.ps1")}"`, + ]; + break; + case Shell.Fish: + shellArgs = [ + "--init-command", + `. ${path.join(shellFolderPath, "shellIntegration.fish").replace(/(\s+)/g, "\\$1")}`, + ]; + break; + case Shell.Xonsh: { + const sharedConfig = os.platform() == "win32" + ? path.join("C:\\ProgramData", "xonsh", "xonshrc") + : path.join("etc", "xonsh", "xonshrc"); + const userConfigs = [ + path.join(os.homedir(), ".xonshrc"), + path.join(os.homedir(), ".config", "xonsh", "rc.xsh"), + path.join(os.homedir(), ".config", "xonsh", "rc.d"), + ]; + const configs = [sharedConfig, ...userConfigs].filter((config) => fs.existsSync(config)); + shellArgs = [ + "-m", + "xonsh", + "--rc", + ...configs, + path.join(shellFolderPath, "shellIntegration.xsh"), + ]; + shellTarget = await getPythonPath(); + break; + } + } + return { shellTarget, shellArgs }; +}; +export const shellEnv = (shell) => { + const env = { + ...process.env, + }; + switch (shell) { + case Shell.Cmd: { + return { ...env, PROMPT: "$G " }; + } + case Shell.Zsh: { + return { ...env, ZDOTDIR: zdotdir, USER_ZDOTDIR: userZdotdir }; + } + } + return env; +}; +export const setupZshDotfiles = async () => { + const shellFolderPath = path.join(path.dirname(url.fileURLToPath(import.meta.url)), "..", "..", "shell"); + await fsAsync.cp(path.join(shellFolderPath, "shellIntegration-rc.zsh"), path.join(zdotdir, ".zshrc")); + await fsAsync.cp(path.join(shellFolderPath, "shellIntegration-profile.zsh"), path.join(zdotdir, ".zprofile")); + await fsAsync.cp(path.join(shellFolderPath, "shellIntegration-env.zsh"), path.join(zdotdir, ".zshenv")); + await fsAsync.cp(path.join(shellFolderPath, "shellIntegration-login.zsh"), path.join(zdotdir, ".zlogin")); +}; +export const getPythonPath = async () => { + return await which("python", { nothrow: true }); +}; +const gitBashPath = async () => { + const gitBashPaths = await getGitBashPaths(); + for (const gitBashPath of gitBashPaths) { + if (fs.existsSync(gitBashPath)) { + return gitBashPath; + } + } + throw new Error("unable to find a git bash executable installed"); +}; +const getGitBashPaths = async () => { + const gitDirs = new Set(); + const gitExePath = await which("git.exe", { nothrow: true }); + if (gitExePath) { + const gitExeDir = path.dirname(gitExePath); + gitDirs.add(path.resolve(gitExeDir, "../..")); + } + const addValid = (set, value) => { + if (value) + set.add(value); + }; + // Add common git install locations + addValid(gitDirs, process.env["ProgramW6432"]); + addValid(gitDirs, process.env["ProgramFiles"]); + addValid(gitDirs, process.env["ProgramFiles(X86)"]); + addValid(gitDirs, `${process.env["LocalAppData"]}\\Program`); + const gitBashPaths = []; + for (const gitDir of gitDirs) { + gitBashPaths.push(`${gitDir}\\Git\\bin\\bash.exe`, `${gitDir}\\Git\\usr\\bin\\bash.exe`, `${gitDir}\\usr\\bin\\bash.exe` // using Git for Windows SDK + ); + } + // Add special installs that don't follow the standard directory structure + gitBashPaths.push(`${process.env["UserProfile"]}\\scoop\\apps\\git\\current\\bin\\bash.exe`); + gitBashPaths.push(`${process.env["UserProfile"]}\\scoop\\apps\\git-with-openssh\\current\\bin\\bash.exe`); + return gitBashPaths; +}; diff --git a/lib/terminal/term.d.ts b/lib/terminal/term.d.ts new file mode 100644 index 0000000..2b95bdc --- /dev/null +++ b/lib/terminal/term.d.ts @@ -0,0 +1,194 @@ +/// +import { IEvent } from "@homebridge/node-pty-prebuilt-multiarch"; +import { EventEmitter } from "node:events"; +import { Shell } from "./shell.js"; +import { Locator } from "./locator.js"; +type TerminalOptions = { + env?: { + [key: string]: string | undefined; + }; + rows: number; + cols: number; + shell: Shell; + shellArgs?: string[]; + program?: { + file: string; + args?: string[]; + }; +}; +type CursorPosition = { + /** + * The x position of the cursor. This ranges between 0 (left side) and Terminal.cols (after last cell of the row). + */ + x: number; + /** + * The y position of the cursor. This ranges between 0 (when the cursor is at baseY) and Terminal.rows - 1 (when the cursor is on the last row). + */ + y: number; + /** + * The line within the buffer where the top of the bottom page is (when fully scrolled down). + */ + baseY: number; +}; +export declare const spawn: (options: TerminalOptions, trace: boolean, traceEmitter: EventEmitter) => Promise; +type CellShift = { + bgColorMode?: number; + bgColor?: number; + fgColorMode?: number; + fgColor?: number; + blink?: number; + bold?: number; + dim?: number; + inverse?: number; + invisible?: number; + italic?: number; + overline?: number; + strike?: number; + underline?: number; +}; +export declare class Terminal { + private _rows; + private _cols; + private _trace; + private _shell; + private _traceEmitter; + private readonly _pty; + private readonly _term; + private readonly _returnChar; + readonly onExit: IEvent<{ + exitCode: number; + signal?: number; + }>; + constructor(target: string, args: string[], _rows: number, _cols: number, _trace: boolean, _shell: Shell, _traceEmitter: EventEmitter, env?: { + [key: string]: string | undefined; + }); + /** + * Change the size of the terminal + * + * @param columns Count of column cells + * @param rows Count of row cells + */ + resize(columns: number, rows: number): void; + /** + * Write the provided data through to the shell + * + * @param data Data to write to the shell + */ + write(data: string): void; + /** + * Write the provided data through to the shell and submit with a return character. + * If running a program with no shell selected, the return character will use the return + * character for the default shell. + * + * @param data Data to write to the shell + */ + submit(data?: string): void; + /** + * Press up arrow key a specific amount of times. + * + * @param count Count of cells to move up. Default is `1`. + */ + keyUp(count?: number | undefined): void; + /** + * Press down arrow key a specific amount of times. + * + * @param count Count of cells to move down. Default is `1`. + */ + keyDown(count?: number | undefined): void; + /** + * Press left arrow key a specific amount of times. + * + * @param count Count of cells to move left. Default is `1`. + */ + keyLeft(count?: number | undefined): void; + /** + * Press right arrow key a specific amount of times. + * + * @param count Count of cells to move right. Default is `1`. + */ + keyRight(count?: number | undefined): void; + /** + * Press escape key a specific amount of times. + * + * @param count Count of key presses. Default is `1`. + */ + keyEscape(count?: number | undefined): void; + /** + * Press delete key a specific amount of times. + * + * @param count Count of key presses. Default is `1`. + */ + keyDelete(count?: number | undefined): void; + /** + * Press backspace key a specific amount of times. + * + * @param count Count of key presses. Default is `1`. + */ + keyBackspace(count?: number | undefined): void; + /** + * Press Ctrl+C key combination a specific amount of times. + * + * @param count Count of key presses. Default is `1`. + */ + keyCtrlC(count?: number | undefined): void; + /** + * Press Ctrl+D key combination a specific amount of times. + * + * @param count Count of key presses. Default is `1`. + */ + keyCtrlD(count?: number | undefined): void; + /** + * Get an array representation of the entire active terminal buffer + * + * @returns an array representation of the buffer + */ + getBuffer(): string[][]; + /** + * Get an array representation of the visible active terminal buffer + * + * @returns an array representation of the buffer + */ + getViewableBuffer(): string[][]; + private _getBuffer; + /** + * Get the terminal's cursor positions + * + * @returns the cursor's positions + */ + getCursor(): CursorPosition; + private _shift; + /** + * Creates a locator for the terminal to search for cells matching the + * given pattern + * + * @param text + * @param options + */ + getByText(text: string | RegExp, options?: { + /** + * Whether to check the entire terminal buffer for the value instead of only the visible section. + */ + full?: boolean; + /** + * Whether to throw errors when the locator can match multiple sets of cells + * + * @default true + */ + strict?: boolean; + }): Locator; + /** + * Serialize the terminal into an encoding for snapshots + * + * @returns snapshot information + */ + serialize(): { + view: string; + shifts: Map; + }; + private _box; + /** + * Kill the terminal and underlying processes + */ + kill(): void; +} +export {}; diff --git a/lib/terminal/term.js b/lib/terminal/term.js new file mode 100644 index 0000000..20d519d --- /dev/null +++ b/lib/terminal/term.js @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import pty from "@homebridge/node-pty-prebuilt-multiarch"; +import xterm from "@xterm/headless"; +import process from "node:process"; +import ansi from "./ansi.js"; +import { Shell, shellLaunch, shellEnv } from "./shell.js"; +import which from "which"; +import { Locator } from "./locator.js"; +export const spawn = async (options, trace, traceEmitter) => { + if (options.program != null) { + const { file, args } = options.program; + const resolvedFile = await which(file, { nothrow: true }); + if (resolvedFile == null) { + throw new Error(`unable to spawn terminal, unable to resolve file '${file}' from PATH`); + } + return new Terminal(resolvedFile, args ?? [], options.rows, options.cols, trace, options.shell, traceEmitter, options.env); + } + const { shellTarget, shellArgs } = await shellLaunch(options.shell); + return new Terminal(shellTarget, options.shellArgs ?? shellArgs ?? [], options.rows, options.cols, trace, options.shell, traceEmitter, { ...shellEnv(options.shell), ...options.env }); +}; +export class Terminal { + _rows; + _cols; + _trace; + _shell; + _traceEmitter; + _pty; + _term; + _returnChar; + onExit; + constructor(target, args, _rows, _cols, _trace, _shell, _traceEmitter, env) { + this._rows = _rows; + this._cols = _cols; + this._trace = _trace; + this._shell = _shell; + this._traceEmitter = _traceEmitter; + this._returnChar = this._shell == Shell.Xonsh ? "\n" : "\r"; + this._pty = pty.spawn(target, args ?? [], { + name: "xterm-256color", + cols: this._cols, + rows: this._rows, + cwd: process.cwd(), + env, + }); + this._term = new xterm.Terminal({ + allowProposedApi: true, + rows: this._rows, + cols: this._cols, + }); + if (this._trace) { + this._traceEmitter.emit("size", this._rows, this._cols); + } + this._pty.onData((data) => { + if (this._trace) { + this._traceEmitter.emit("data", data, Date.now()); + } + this._term.write(data); + }); + this.onExit = this._pty.onExit; + } + /** + * Change the size of the terminal + * + * @param columns Count of column cells + * @param rows Count of row cells + */ + resize(columns, rows) { + this._cols = columns; + this._rows = rows; + this._pty.resize(columns, rows); + this._term.resize(columns, rows); + if (this._trace) { + this._traceEmitter.emit("size", rows, columns); + } + } + /** + * Write the provided data through to the shell + * + * @param data Data to write to the shell + */ + write(data) { + this._pty.write(data); + } + /** + * Write the provided data through to the shell and submit with a return character. + * If running a program with no shell selected, the return character will use the return + * character for the default shell. + * + * @param data Data to write to the shell + */ + submit(data) { + this._pty.write(`${data ?? ""}${this._returnChar}`); + } + /** + * Press up arrow key a specific amount of times. + * + * @param count Count of cells to move up. Default is `1`. + */ + keyUp(count) { + this._pty.write(ansi.keyUp.repeat(count ?? 1)); + } + /** + * Press down arrow key a specific amount of times. + * + * @param count Count of cells to move down. Default is `1`. + */ + keyDown(count) { + this._pty.write(ansi.keyDown.repeat(count ?? 1)); + } + /** + * Press left arrow key a specific amount of times. + * + * @param count Count of cells to move left. Default is `1`. + */ + keyLeft(count) { + this._pty.write(ansi.keyLeft.repeat(count ?? 1)); + } + /** + * Press right arrow key a specific amount of times. + * + * @param count Count of cells to move right. Default is `1`. + */ + keyRight(count) { + this._pty.write(ansi.keyRight.repeat(count ?? 1)); + } + /** + * Press escape key a specific amount of times. + * + * @param count Count of key presses. Default is `1`. + */ + keyEscape(count) { + this._pty.write(ansi.ESC.repeat(count ?? 1)); + } + /** + * Press delete key a specific amount of times. + * + * @param count Count of key presses. Default is `1`. + */ + keyDelete(count) { + this._pty.write(ansi.keyDelete.repeat(count ?? 1)); + } + /** + * Press backspace key a specific amount of times. + * + * @param count Count of key presses. Default is `1`. + */ + keyBackspace(count) { + this._pty.write(ansi.keyBackspace.repeat(count ?? 1)); + } + /** + * Press Ctrl+C key combination a specific amount of times. + * + * @param count Count of key presses. Default is `1`. + */ + keyCtrlC(count) { + this._pty.write(ansi.keyCtrlC.repeat(count ?? 1)); + } + /** + * Press Ctrl+D key combination a specific amount of times. + * + * @param count Count of key presses. Default is `1`. + */ + keyCtrlD(count) { + this._pty.write(ansi.keyCtrlD.repeat(count ?? 1)); + } + /** + * Get an array representation of the entire active terminal buffer + * + * @returns an array representation of the buffer + */ + getBuffer() { + return this._getBuffer(0, this._term.buffer.active.length); + } + /** + * Get an array representation of the visible active terminal buffer + * + * @returns an array representation of the buffer + */ + getViewableBuffer() { + return this._getBuffer(this._term.buffer.active.baseY, this._term.buffer.active.length); + } + _getBuffer(startY, endY) { + const lines = []; + for (let y = startY; y < endY; y++) { + const termLine = this._term.buffer.active.getLine(y); + const line = []; + let cell = termLine?.getCell(0); + for (let x = 0; x < this._term.cols; x++) { + cell = termLine?.getCell(x, cell); + const rawChars = cell?.getChars() ?? ""; + const chars = rawChars === "" ? " " : rawChars; + line.push(chars); + } + lines.push(line); + } + return lines; + } + /** + * Get the terminal's cursor positions + * + * @returns the cursor's positions + */ + getCursor() { + return { + x: this._term.buffer.active.cursorX, + y: this._term.buffer.active.cursorY, + baseY: this._term.buffer.active.baseY, + }; + } + _shift(baseCell, targetCell) { + const result = {}; + if (!(baseCell?.getBgColorMode() == targetCell?.getBgColorMode() && + baseCell?.getBgColor() == targetCell?.getBgColor())) { + result.bgColorMode = targetCell?.getBgColorMode(); + result.bgColor = targetCell?.getBgColor(); + } + if (!(baseCell?.getFgColorMode() == targetCell?.getFgColorMode() && + baseCell?.getFgColor() == targetCell?.getFgColor())) { + result.fgColorMode = targetCell?.getFgColorMode(); + result.fgColor = targetCell?.getFgColor(); + } + if (baseCell?.isBlink() !== targetCell?.isBlink()) { + result.blink = targetCell?.isBlink(); + } + if (baseCell?.isBold() !== targetCell?.isBold()) { + result.bold = targetCell?.isBold(); + } + if (baseCell?.isDim() !== targetCell?.isDim()) { + result.dim = targetCell?.isDim(); + } + if (baseCell?.isInverse() !== targetCell?.isInverse()) { + result.inverse = targetCell?.isInverse(); + } + if (baseCell?.isInvisible() !== targetCell?.isInvisible()) { + result.invisible = targetCell?.isInvisible(); + } + if (baseCell?.isItalic() !== targetCell?.isItalic()) { + result.italic = targetCell?.isItalic(); + } + if (baseCell?.isOverline() !== targetCell?.isOverline()) { + result.overline = targetCell?.isOverline(); + } + if (baseCell?.isStrikethrough() !== targetCell?.isStrikethrough()) { + result.strike = targetCell?.isStrikethrough(); + } + if (baseCell?.isUnderline() !== targetCell?.isUnderline()) { + result.underline = targetCell?.isUnderline(); + } + return result; + } + /** + * Creates a locator for the terminal to search for cells matching the + * given pattern + * + * @param text + * @param options + */ + getByText(text, options) { + return new Locator(text, this, this._term, options?.full, options?.strict ?? true); + } + /** + * Serialize the terminal into an encoding for snapshots + * + * @returns snapshot information + */ + serialize() { + const shifts = new Map(); + const lines = []; + const empty = (o) => Object.keys(o).length === 0; + let prevCell = undefined; + for (let y = this._term.buffer.active.baseY; y < this._term.buffer.active.length; y++) { + const line = this._term.buffer.active.getLine(y); + const lineView = []; + if (line == null) + continue; + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x); + const chars = cell?.getChars() ?? ""; + lineView.push(chars.length === 0 ? " " : chars); + const shift = this._shift(prevCell, cell); + if (!empty(shift)) { + shifts.set(`${x},${y}`, shift); + } + prevCell = cell; + } + lines.push(lineView.join("")); + } + const view = this._box(lines.join("\n"), this._term.cols); + return { view, shifts }; + } + _box(view, width) { + const top = "╭" + "─".repeat(width) + "╮"; + const bottom = "╰" + "─".repeat(width) + "╯"; + return [ + top, + ...view.split("\n").map((line) => "│" + line + "│"), + bottom, + ].join("\n"); + } + /** + * Kill the terminal and underlying processes + */ + kill() { + process.kill(this._pty.pid, 9); + } +} diff --git a/lib/test/error.d.ts b/lib/test/error.d.ts new file mode 100644 index 0000000..46211f9 --- /dev/null +++ b/lib/test/error.d.ts @@ -0,0 +1 @@ +export declare const isStrictModeViolation: (error: string | Error | unknown) => boolean; diff --git a/lib/test/error.js b/lib/test/error.js new file mode 100644 index 0000000..11798e2 --- /dev/null +++ b/lib/test/error.js @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { strictModeErrorPrefix } from "../utils/constants.js"; +const getErrorMessage = (e) => typeof e == "string" ? e : e instanceof Error ? e.message : ""; +export const isStrictModeViolation = (error) => getErrorMessage(error).startsWith(strictModeErrorPrefix); diff --git a/lib/test/matchers/toBeVisible.d.ts b/lib/test/matchers/toBeVisible.d.ts new file mode 100644 index 0000000..1ef7a07 --- /dev/null +++ b/lib/test/matchers/toBeVisible.d.ts @@ -0,0 +1,6 @@ +import type { MatcherContext, AsyncExpectationResult } from "expect"; +import { Locator } from "../../terminal/locator.js"; +export declare function toBeVisible(this: MatcherContext, locator: Locator, options?: { + timeout?: number; + full?: boolean; +}): AsyncExpectationResult; diff --git a/lib/test/matchers/toBeVisible.js b/lib/test/matchers/toBeVisible.js new file mode 100644 index 0000000..6e4861a --- /dev/null +++ b/lib/test/matchers/toBeVisible.js @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import chalk from "chalk"; +import { getExpectTimeout } from "../../config/config.js"; +import { isStrictModeViolation } from "../error.js"; +export async function toBeVisible(locator, options) { + const expected = locator.searchTerm(); + const comparisonMethod = typeof expected === "string" + ? "String.prototype.includes search" + : "String.prototype.matchAll search"; + let pass = true; + let errorMessage = ""; + try { + pass = + (await locator.resolve(options?.timeout ?? getExpectTimeout(), this.isNot)) != null; + } + catch (e) { + const errorMessage = typeof e == "string" ? e : e instanceof Error ? e.message : ""; + pass = (isStrictModeViolation(e) && this.isNot) || false; + return { + pass, + message: () => { + if (!this.isNot) { + return `expect(${chalk.red("received")}).toBeVisible(${chalk.green("expected")}) ${chalk.dim("// " + comparisonMethod)}\n\n${errorMessage}`; + } + return `expect(${chalk.red("received")}).not.toBeVisible(${chalk.green("expected")}) ${chalk.dim("// " + comparisonMethod)}\n\n${errorMessage}`; + }, + }; + } + errorMessage = errorMessage != "" ? `\n\n${errorMessage}` : ""; + return { + pass, + message: () => { + if (!pass && !this.isNot) { + return (`expect(${chalk.red("received")}).toBeVisible(${chalk.green("expected")}) ${chalk.dim("// " + comparisonMethod)}` + + `\n\nExpected: ${chalk.green(expected.toString())}\nMatches Found: ${chalk.red(0)}${errorMessage}`); + } + if (pass && this.isNot) { + return (`expect(${chalk.red("received")}).not.toBeVisible(${chalk.green("expected")}) ${chalk.dim("// " + comparisonMethod)}` + + `\n\nExpected: ${chalk.green("0 matches")}\nFound: ${chalk.red(expected.toString())}${errorMessage}`); + } + return "passed"; + }, + }; +} diff --git a/lib/test/matchers/toHaveBgColor.d.ts b/lib/test/matchers/toHaveBgColor.d.ts new file mode 100644 index 0000000..13eab6f --- /dev/null +++ b/lib/test/matchers/toHaveBgColor.d.ts @@ -0,0 +1,5 @@ +import type { MatcherContext, AsyncExpectationResult } from "expect"; +import { Locator } from "../../terminal/locator.js"; +export declare function toHaveBgColor(this: MatcherContext, locator: Locator, expected: string | number | [number, number, number], options?: { + timeout?: number; +}): AsyncExpectationResult; diff --git a/lib/test/matchers/toHaveBgColor.js b/lib/test/matchers/toHaveBgColor.js new file mode 100644 index 0000000..32882b0 --- /dev/null +++ b/lib/test/matchers/toHaveBgColor.js @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import convert from "color-convert"; +import chalk from "chalk"; +import { getExpectTimeout } from "../../config/config.js"; +export async function toHaveBgColor(locator, expected, options) { + const cells = await locator.resolve(options?.timeout ?? getExpectTimeout()); + const [result, errorCell] = hasBgColor(cells ?? [], expected, this.isNot ?? false); + const pass = this.isNot ? !result : result; + const badColor = toMatchingColorMode(expected, errorCell); + return { + pass, + message: () => { + if (!pass && !this.isNot) { + return (`expect(${chalk.red("received")}).toHaveBgColor(${chalk.green("expected")})` + + `\n\nExpected Color: ${chalk.green(expected.toString())}\nFound Color: ${chalk.red(badColor)} in cell "${errorCell?.termCell?.getChars()}" at ${errorCell?.x},${errorCell?.y}`); + } + if (pass && this.isNot) { + return (`expect(${chalk.red("received")}).not.toHaveBgColor(${chalk.green("expected")})` + + `\n\nExpected No Occurrences Of Color: ${chalk.green(expected.toString())}\nFound Color: ${chalk.red(badColor)} in cell "${errorCell?.termCell?.getChars()}" at ${errorCell?.x},${errorCell?.y}`); + } + return "passed"; + }, + }; +} +function toMatchingColorMode(expected, cell) { + if (cell == null) + return ""; + const { termCell } = cell; + if (typeof expected == "string") { + return termCell?.isBgDefault() + ? "000000" + : termCell?.isBgPalette() + ? convert.ansi256.hex(termCell.getBgColor()) + : termCell?.getBgColor().toString(16) ?? ""; + } + else if (Array.isArray(expected)) { + return termCell?.isBgDefault() + ? "[0,0,0]" + : termCell?.isBgPalette() + ? convert.ansi256.rgb(termCell.getBgColor()).toString() + : convert.hex.rgb(termCell.getBgColor().toString(16)).toString(); + } + else { + return termCell?.isBgDefault() + ? "0" + : termCell?.isBgPalette() + ? termCell.getBgColor().toString() + : convert.hex.ansi256(termCell.getBgColor().toString(16)).toString(); + } +} +function hasBgColor(cells, color, isNot) { + if (Array.isArray(color)) { + const [red, green, blue] = color; + const badCells = cells.filter((cell) => { + const { termCell } = cell; + const valid = termCell?.isBgDefault() + ? red == 0 && blue == 0 && green == 0 + : termCell?.isBgPalette() + ? termCell.getBgColor() == convert.rgb.ansi256(color) + : termCell?.getBgColor().toString(16) === convert.rgb.hex(color); + return isNot ? valid : !valid; + }); + if (badCells.length > 0) + return [false, badCells[0]]; + } + else if (typeof color == "number") { + const badCells = cells.filter((cell) => { + const { termCell } = cell; + const valid = termCell?.isBgDefault() + ? color === -1 || color === 0 + : termCell?.isBgPalette() + ? termCell.getBgColor() === color + : termCell?.getBgColor().toString(16) === convert.ansi256.hex(color); + return isNot ? valid : !valid; + }); + if (badCells.length > 0) + return [false, badCells[0]]; + } + else if (typeof color == "string") { + const badCells = cells.filter((cell) => { + const { termCell } = cell; + const valid = termCell?.isBgDefault() + ? convert.hex.ansi256(color) === 0 + : termCell?.isBgPalette() + ? termCell.getBgColor() === convert.hex.ansi256(color) + : termCell?.getBgColor().toString(16) === color; + return isNot ? valid : !valid; + }); + if (badCells.length > 0) + return [false, badCells[0]]; + } + return [true, undefined]; +} diff --git a/lib/test/matchers/toHaveFgColor.d.ts b/lib/test/matchers/toHaveFgColor.d.ts new file mode 100644 index 0000000..97b85ec --- /dev/null +++ b/lib/test/matchers/toHaveFgColor.d.ts @@ -0,0 +1,5 @@ +import type { MatcherContext, AsyncExpectationResult } from "expect"; +import { Locator } from "../../terminal/locator.js"; +export declare function toHaveFgColor(this: MatcherContext, locator: Locator, expected: string | number | [number, number, number], options?: { + timeout?: number; +}): AsyncExpectationResult; diff --git a/lib/test/matchers/toHaveFgColor.js b/lib/test/matchers/toHaveFgColor.js new file mode 100644 index 0000000..6d16c40 --- /dev/null +++ b/lib/test/matchers/toHaveFgColor.js @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import convert from "color-convert"; +import chalk from "chalk"; +import { getExpectTimeout } from "../../config/config.js"; +export async function toHaveFgColor(locator, expected, options) { + const cells = await locator.resolve(options?.timeout ?? getExpectTimeout()); + const [result, errorCell] = hasFgColor(cells ?? [], expected, this.isNot ?? false); + const pass = this.isNot ? !result : result; + const badColor = toMatchingColorMode(expected, errorCell); + return { + pass, + message: () => { + if (!pass && !this.isNot) { + return (`expect(${chalk.red("received")}).toHaveFgColor(${chalk.green("expected")})` + + `\n\nExpected Color: ${chalk.green(expected.toString())}\nFound Color: ${chalk.red(badColor)} in cell "${errorCell?.termCell?.getChars()}" at ${errorCell?.x},${errorCell?.y}`); + } + if (pass && this.isNot) { + return (`expect(${chalk.red("received")}).not.toHaveFgColor(${chalk.green("expected")})` + + `\n\nExpected No Occurrences Of Color: ${chalk.green(expected.toString())}\nFound Color: ${chalk.red(badColor)} in cell "${errorCell?.termCell?.getChars()}" at ${errorCell?.x},${errorCell?.y}`); + } + return "passed"; + }, + }; +} +function toMatchingColorMode(expected, cell) { + if (cell == null) + return ""; + const { termCell } = cell; + if (typeof expected == "string") { + return termCell?.isFgDefault() + ? "000000" + : termCell?.isFgPalette() + ? convert.ansi256.hex(termCell.getFgColor()) + : termCell?.getFgColor().toString(16) ?? ""; + } + else if (Array.isArray(expected)) { + return termCell?.isFgDefault() + ? "[0,0,0]" + : termCell?.isFgPalette() + ? convert.ansi256.rgb(termCell.getFgColor()).toString() + : convert.hex.rgb(termCell.getFgColor().toString(16)).toString(); + } + else { + return termCell?.isFgDefault() + ? "0" + : termCell?.isFgPalette() + ? termCell.getFgColor().toString() + : convert.hex.ansi256(termCell.getFgColor().toString(16)).toString(); + } +} +function hasFgColor(cells, color, isNot) { + if (Array.isArray(color)) { + const [red, green, blue] = color; + const badCells = cells.filter((cell) => { + const { termCell } = cell; + const valid = termCell?.isFgDefault() + ? red == 0 && blue == 0 && green == 0 + : termCell?.isFgPalette() + ? termCell.getFgColor() == convert.rgb.ansi256(color) + : termCell?.getFgColor().toString(16) === convert.rgb.hex(color); + return isNot ? valid : !valid; + }); + if (badCells.length > 0) + return [false, badCells[0]]; + } + else if (typeof color == "number") { + const badCells = cells.filter((cell) => { + const { termCell } = cell; + const valid = termCell?.isFgDefault() + ? color === -1 || color === 0 + : termCell?.isFgPalette() + ? termCell.getFgColor() === color + : termCell?.getFgColor().toString(16) === convert.ansi256.hex(color); + return isNot ? valid : !valid; + }); + if (badCells.length > 0) + return [false, badCells[0]]; + } + else if (typeof color == "string") { + const badCells = cells.filter((cell) => { + const { termCell } = cell; + const valid = termCell?.isFgDefault() + ? convert.hex.ansi256(color) === 0 + : termCell?.isFgPalette() + ? termCell.getFgColor() === convert.hex.ansi256(color) + : termCell?.getFgColor().toString(16) === color; + return isNot ? valid : !valid; + }); + if (badCells.length > 0) + return [false, badCells[0]]; + } + return [true, undefined]; +} diff --git a/lib/test/matchers/toMatchSnapshot.d.ts b/lib/test/matchers/toMatchSnapshot.d.ts new file mode 100644 index 0000000..77dc49a --- /dev/null +++ b/lib/test/matchers/toMatchSnapshot.d.ts @@ -0,0 +1,8 @@ +import type { MatcherContext, AsyncExpectationResult } from "expect"; +import { Terminal } from "../../terminal/term.js"; +export type SnapshotStatus = "passed" | "failed" | "written" | "updated"; +export declare const cleanSnapshot: (testPath: string, retainedSnapshots: Set, updateSnapshot: boolean) => Promise; +export declare const flushSnapshotExecutionCache: () => void; +export declare function toMatchSnapshot(this: MatcherContext, terminal: Terminal, options?: { + includeColors?: boolean; +}): AsyncExpectationResult; diff --git a/lib/test/matchers/toMatchSnapshot.js b/lib/test/matchers/toMatchSnapshot.js new file mode 100644 index 0000000..0c7a3b8 --- /dev/null +++ b/lib/test/matchers/toMatchSnapshot.js @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { diffStringsUnified } from "jest-diff"; +import path from "node:path"; +import process from "node:process"; +import fs from "node:fs"; +import fsAsync from "node:fs/promises"; +import module from "node:module"; +import workpool from "workerpool"; +import lockfile from "proper-lockfile"; +const require = module.createRequire(import.meta.url); +const snapshots = new Map(); +const snapshotsIdx = new Map(); +const snapshotPath = (testPath) => path.join(process.cwd(), path.dirname(testPath), "__snapshots__", `${path.basename(testPath)}.snap`); +const loadSnapshot = async (testPath, testName) => { + let snaps; + if (snapshots.has(testPath)) { + snaps = snapshots.get(testPath); + } + else { + const snapPath = snapshotPath(testPath); + if (!fs.existsSync(snapPath)) { + return; + } + snaps = require(snapPath); + snapshots.set(testPath, snaps); + } + return Object.hasOwn(snaps, testName) ? snaps[testName].trim() : undefined; +}; +const updateSnapshot = async (testPath, testName, snapshot) => { + const snapPath = snapshotPath(testPath); + if (!fs.existsSync(path.dirname(snapPath))) { + await fsAsync.mkdir(path.dirname(snapPath), { recursive: true }); + } + if (!fs.existsSync(snapPath)) { + await fsAsync.appendFile(snapPath, ""); + } + const unlock = await lockfile.lock(snapPath, { + stale: 5000, + retries: { + retries: 5, + minTimeout: 50, + maxTimeout: 1000, + randomize: true, + }, + }); + delete require.cache[require.resolve(snapPath)]; + const snapshots = require(snapPath); + snapshots[testName] = snapshot; + await fsAsync.writeFile(snapPath, "// TUI Test Snapshot v1\n\n" + + Object.keys(snapshots) + .sort() + .map((snapshotName) => `exports[\`${snapshotName}\`] = String.raw\`\n${snapshots[snapshotName].trim()}\n\`;\n\n`) + .join("")); + await unlock(); +}; +export const cleanSnapshot = async (testPath, retainedSnapshots, updateSnapshot) => { + const snapPath = snapshotPath(testPath); + if (!fs.existsSync(snapPath)) + return retainedSnapshots.size; + const snapshots = require(snapPath); + const unusedSnapshots = Object.keys(snapshots).filter((snapshot) => !retainedSnapshots.has(snapshot)); + if (!updateSnapshot) + return unusedSnapshots.length; + unusedSnapshots.forEach((unusedSnapshot) => { + delete snapshots[unusedSnapshot]; + }); + await fsAsync.writeFile(snapPath, "// TUI Test Snapshot v1\n\n" + + Object.keys(snapshots) + .sort() + .map((snapshotName) => `exports[\`${snapshotName}\`] = String.raw\`\n${snapshots[snapshotName].trim()}\n\`;\n\n`) + .join("")); + return unusedSnapshots.length; +}; +const generateSnapshot = (terminal, includeColors) => { + const { view, shifts } = terminal.serialize(); + if (shifts.size === 0 || !includeColors) { + return view; + } + return `${view}\n${JSON.stringify(Object.fromEntries(shifts), null, 2)}`; +}; +export const flushSnapshotExecutionCache = () => snapshotsIdx.clear(); +export async function toMatchSnapshot(terminal, options) { + const testName = (this.currentConcurrentTestName || (() => ""))() ?? ""; + const snapshotIdx = snapshotsIdx.get(testName) ?? 0; + const snapshotPostfixTestName = snapshotIdx != null && snapshotIdx != 0 + ? `${testName} ${snapshotIdx}` + : testName; + snapshotsIdx.set(testName, snapshotIdx + 1); + const existingSnapshot = await loadSnapshot(this.testPath ?? "", snapshotPostfixTestName); + const newSnapshot = generateSnapshot(terminal, options?.includeColors ?? false); + const snapshotsDifferent = existingSnapshot !== newSnapshot; + const snapshotShouldUpdate = globalThis.__expectState.updateSnapshot && snapshotsDifferent; + const snapshotEmpty = existingSnapshot == null; + const emitResult = () => { + if (!workpool.isMainThread) { + const snapshotResult = snapshotEmpty + ? "written" + : snapshotShouldUpdate + ? "updated" + : snapshotsDifferent + ? "failed" + : "passed"; + workpool.workerEmit({ + snapshotResult, + snapshotName: snapshotPostfixTestName, + }); + } + }; + if (snapshotEmpty || snapshotShouldUpdate) { + await updateSnapshot(this.testPath ?? "", snapshotPostfixTestName, newSnapshot); + emitResult(); + return { + pass: true, + message: () => "", + }; + } + else { + emitResult(); + } + return { + pass: !snapshotsDifferent, + message: !snapshotsDifferent + ? () => "" + : () => diffStringsUnified(existingSnapshot, newSnapshot ?? ""), + }; +} diff --git a/lib/test/option.d.ts b/lib/test/option.d.ts new file mode 100644 index 0000000..a74ad8c --- /dev/null +++ b/lib/test/option.d.ts @@ -0,0 +1,34 @@ +import { Shell } from "../terminal/shell.js"; +export interface TestOptions { + /** + * The shell to initialize the terminal with. Defaults to `cmd` on Windows and `bash` on macOS/Linux. + */ + shell?: Shell; + /** + * The number of rows to initialize the terminal with. Defaults to 30. + */ + rows?: number; + /** + * The number of columns to initialize the terminal with. Defaults to 80. + */ + columns?: number; + /** + * Environment to be set for the shell. + */ + env?: { + [key: string]: string | undefined; + }; + /** + * The program to initialize the terminal with. Overrides the `shell` option + */ + program?: { + /** + * The file to launch + */ + file: string; + /** + * The file's arguments as argv + */ + args?: string[]; + }; +} diff --git a/lib/test/option.js b/lib/test/option.js new file mode 100644 index 0000000..5a573bb --- /dev/null +++ b/lib/test/option.js @@ -0,0 +1,3 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +export {}; diff --git a/lib/test/suite.d.ts b/lib/test/suite.d.ts new file mode 100644 index 0000000..43d820c --- /dev/null +++ b/lib/test/suite.d.ts @@ -0,0 +1,19 @@ +import { TestOptions } from "./option.js"; +import { TestConfig } from "../config/config.js"; +import type { TestCase } from "./testcase.js"; +type SuiteType = "file" | "describe" | "project" | "root"; +export declare class Suite { + readonly title: string; + readonly type: SuiteType; + options?: TestOptions | undefined; + parentSuite?: Suite | undefined; + suites: Suite[]; + tests: TestCase[]; + source?: string; + constructor(title: string, type: SuiteType, options?: TestOptions | undefined, parentSuite?: Suite | undefined); + allTests(): TestCase[]; + titlePath(): string[]; +} +export declare const suiteFilePath: (suite: Suite) => string | undefined; +export declare const getRootSuite: (config: Required) => Promise; +export {}; diff --git a/lib/test/suite.js b/lib/test/suite.js new file mode 100644 index 0000000..f5d4fce --- /dev/null +++ b/lib/test/suite.js @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { glob } from "glob"; +export class Suite { + title; + type; + options; + parentSuite; + suites = []; + tests = []; + source; + constructor(title, type, options, parentSuite) { + this.title = title; + this.type = type; + this.options = options; + this.parentSuite = parentSuite; + } + allTests() { + const suitesIterable = [...this.suites]; + const tests = []; + while (suitesIterable.length != 0) { + const suite = suitesIterable.shift(); + tests.push(...(suite?.tests ?? [])); + suitesIterable.push(...(suite?.suites ?? [])); + } + return tests; + } + titlePath() { + const titles = []; + let currentSuite = this.parentSuite; + while (currentSuite != null) { + if (currentSuite.type === "project") { + titles.push(`[${currentSuite.title}]`); + } + else if (currentSuite.type !== "root") { + titles.push(currentSuite.title); + } + currentSuite = currentSuite.parentSuite; + } + return [...titles.reverse(), this.title]; + } +} +export const suiteFilePath = (suite) => { + let currentSuite = suite; + while (currentSuite != null) { + if (currentSuite.type === "file") { + return currentSuite.title; + } + currentSuite = currentSuite.parentSuite; + } +}; +export const getRootSuite = async (config) => { + const projects = [ + { + shell: config.use.shell, + rows: config.use.rows, + columns: config.use.columns, + testMatch: config.testMatch, + name: "", + env: config.use.env, + program: config.use.program, + }, + ...(config.projects?.map((project) => ({ + shell: project.shell ?? config.use.shell, + name: project.name ?? "", + rows: project.rows ?? config.use.rows, + columns: project.columns ?? config.use.columns, + testMatch: project.testMatch, + env: project.env ?? config.use.env, + program: project.program ?? config.use.program, + })) ?? []), + ]; + const suites = (await Promise.all(projects.map(async (project) => { + const files = await glob(project.testMatch, { + ignore: ["**/node_modules/**"], + }); + const suite = new Suite(project.name, "project", { + shell: project.shell, + rows: project.rows, + columns: project.columns, + program: project.program, + }); + suite.suites = files.map((file) => new Suite(file, "file", { + shell: project.shell, + rows: project.rows, + columns: project.columns, + program: project.program, + }, suite)); + return suite; + }))).flat(); + const rootSuite = new Suite("Root Suite", "root", { + shell: config.use.shell, + rows: config.use.rows, + columns: config.use.columns, + program: config.use.program, + }); + rootSuite.suites = suites; + return rootSuite; +}; diff --git a/lib/test/test.d.ts b/lib/test/test.d.ts new file mode 100644 index 0000000..f43b15e --- /dev/null +++ b/lib/test/test.d.ts @@ -0,0 +1,242 @@ +import { Matchers, AsymmetricMatchers, BaseExpect } from "expect"; +import { Suite } from "./suite.js"; +import { TestFunction, TestCase } from "./testcase.js"; +export { Shell } from "../terminal/shell.js"; +import { TestOptions } from "./option.js"; +import { Terminal } from "../terminal/term.js"; +import { TestConfig } from "../config/config.js"; +import { Locator } from "../terminal/locator.js"; +declare global { + var suite: Suite; + var tests: { + [testId: string]: TestCase; + }; + var __expectState: { + updateSnapshot: boolean; + }; +} +/** + * These tests are executed in new terminal context which provides a shell and provides a new pty to each test. + * @param title Test title. + * @param testFunction The test function that is run when calling the test function. + */ +export declare function test(title: string, testFunction: TestFunction): void; +export declare namespace test { + /** + * Specifies options or fixtures to use in a single test file or a test.describe group. Most useful to + * set an option, for example set `shell` to configure the shell initialized for each test. + * + * **Usage** + * + * ```js + * import { test, expect, Shell } from '@microsoft/tui-test'; + * + * test.use({ shell: Shell.Cmd }); + * + * test('test on cmd', async ({ terminal }) => { + * // The terminal now is running the shell that has been specified + * }); + * ``` + * + * **Details** + * + * `test.use` can be called either in the global scope or inside `test.describe`. It is an error to call it within + * `beforeEach` or `beforeAll`. + * ``` + * + * @param options An object with local options. + */ + const use: (options: TestOptions) => void; + /** + * Declares a group of tests. + * + * **Usage** + * + * ```js + * test.describe('two tests', () => { + * test('one', async ({ terminal }) => { + * // ... + * }); + * + * test('two', async ({ terminal }) => { + * // ... + * }); + * }); + * ``` + * + * @param title Group title. + * @param callback A callback that is run immediately when calling test.describe + * Any tests added in this callback will belong to the group. + */ + const describe: (title: string, callback: () => void) => void; + /** + * Declares a skipped test. Skipped test is never run. + * + * **Usage** + * + * ```js + * import { test, expect } from '@microsoft/tui-test'; + * + * test.skip('broken test', async ({ page }) => { + * // ... + * }); + * ``` + * + * @param title Test title. + * @param testFunction The test function that is run when calling the test function. + */ + const skip: (title: string, testFunction: TestFunction) => void; + /** + * Declares a failed test. + * + * **Usage** + * + * ```js + * import { test, expect } from '@microsoft/tui-test'; + * + * test.fail('purposely failing test', async ({ page }) => { + * // ... + * }); + * ``` + * + * @param title Test title. + * @param testFunction The test function that is run when calling the test function. + */ + const fail: (title: string, testFunction: TestFunction) => void; + /** + * Declares a conditional test. + * + * **Usage** + * + * ```js + * import { test, expect } from '@microsoft/tui-test'; + * import os from "node:os"; + * + * const isWindows = os.platform() == "win32" + * test.when(isWindows, 'windows only test', async ({ page }) => { + * // ... + * }); + * ``` + * + * @param shouldRun If the test should be run or skipped. + * @param title Test title. + * @param testFunction The test function that is run when calling the test function. + */ + const when: (shouldRun: boolean, title: string, testFunction: TestFunction) => void; + /** + * Declares a focused test. If there are some focused tests or suites, all of them will be run but nothing else. + * + * **Usage** + * + * ```js + * import { test, expect } from '@microsoft/tui-test'; + * + * test.only('focus this test', async ({ page }) => { + * // Run only focused tests + * }); + * ``` + * + * @param title Test title. + * @param testFunction The test function that is run when calling the test function. + */ + const only: (title: string, testFunction: TestFunction) => void; +} +interface TerminalMatchers { + toMatchSnapshot(options?: { + /** + * Include color information in the snapshot. + */ + includeColors?: boolean; + }): Promise; +} +interface LocatorMatchers { + /** + * Checks that selected text is visible. + * + * **Usage** + * + * ```js + * await expect(terminal.getByText(">")).toBeVisible(); + * ``` + * + * @param options + */ + toBeVisible(options?: { + /** + * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; + /** + * Checks that selected text has the desired background color. + * + * **Usage** + * + * ```js + * await expect(terminal.getByText(">")).toHaveBgColor("#000000"); + * ``` + * + * @param value The desired cell's background color. Can be in the following forms + * - ANSI 256: This is a number from 0 to 255 of ANSI colors `255` + * - Hex: A string representing a 'true color' `#FFFFFF` + * - RGB: An array presenting an rgb color `[255, 255, 255]` + * @param options + */ + toHaveBgColor(value: string | number | [number, number, number], options?: { + /** + * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; + /** + * Checks that selected text has the desired foreground color. + * + * **Usage** + * + * ```js + * await expect(terminal.getByText(">")).toHaveFgColor("#000000"); + * ``` + * + * @param value The desired cell's foreground color. Can be in the following forms + * - ANSI 256: This is a number from 0 to 255 of ANSI colors `255` + * - Hex: A string representing a 'true color' `#FFFFFF` + * - RGB: An array presenting an rgb color `[255, 255, 255]` + * @param options + */ + toHaveFgColor(value: string | number | [number, number, number], options?: { + /** + * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; +} +declare type BaseMatchers = Matchers & Inverse> & PromiseMatchers; +declare type AllowedGenericMatchers = Pick, "toBe" | "toBeDefined" | "toBeFalsy" | "toBeNull" | "toBeTruthy" | "toBeUndefined">; +declare type SpecificMatchers = T extends Terminal ? TerminalMatchers & AllowedGenericMatchers & Inverse> : T extends Locator ? LocatorMatchers & Inverse : BaseMatchers; +export declare type Expect = { + (actual: T): SpecificMatchers; +} & BaseExpect & AsymmetricMatchers & Inverse>; +declare type PromiseMatchers = { + /** + * Unwraps the reason of a rejected promise so any other matcher can be chained. + * If the promise is fulfilled the assertion fails. + */ + rejects: Matchers, T> & Inverse, T>>; + /** + * Unwraps the value of a fulfilled promise so any other matcher can be chained. + * If the promise is rejected the assertion fails. + */ + resolves: Matchers, T> & Inverse, T>>; +}; +declare type Inverse = { + /** + * Inverse next matcher. If you know how to test something, `.not` lets you test its opposite. + */ + not: Matchers; +}; +declare const expect: Expect; +export { expect }; +/** + * Defines tui-test config + */ +export declare function defineConfig(config: TestConfig): TestConfig; diff --git a/lib/test/test.js b/lib/test/test.js new file mode 100644 index 0000000..969e689 --- /dev/null +++ b/lib/test/test.js @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { expect as jestExpect, } from "expect"; +import path from "node:path"; +import { Suite, suiteFilePath } from "./suite.js"; +import { TestCase } from "./testcase.js"; +export { Shell } from "../terminal/shell.js"; +import { toMatchSnapshot } from "./matchers/toMatchSnapshot.js"; +import { toHaveBgColor } from "./matchers/toHaveBgColor.js"; +import { toHaveFgColor } from "./matchers/toHaveFgColor.js"; +import { toBeVisible } from "./matchers/toBeVisible.js"; +const getTestLocation = () => { + const filename = suiteFilePath(globalThis.suite); + const errorStack = new Error().stack; + let location = { row: 0, column: 0 }; + if (errorStack) { + const lineInfo = errorStack + .match(new RegExp(`${path.basename(filename)}(.*)\\)`)) + ?.at(1) + ?.split(":") + ?.slice(-2); + if (lineInfo?.length === 2 && + lineInfo.every((info) => /^\d+$/.test(info))) { + const [row, column] = lineInfo.map((info) => Number(info)); + location = { row, column }; + } + } + return location; +}; +/** + * These tests are executed in new terminal context which provides a shell and provides a new pty to each test. + * @param title Test title. + * @param testFunction The test function that is run when calling the test function. + */ +export function test(title, testFunction) { + const location = getTestLocation(); + const test = new TestCase(title, location, testFunction, globalThis.suite); + if (globalThis.tests != null) { + globalThis.tests[test.id] = test; + } + globalThis.suite.tests.push(test); +} +// eslint-disable-next-line @typescript-eslint/no-namespace +(function (test_1) { + /** + * Specifies options or fixtures to use in a single test file or a test.describe group. Most useful to + * set an option, for example set `shell` to configure the shell initialized for each test. + * + * **Usage** + * + * ```js + * import { test, expect, Shell } from '@microsoft/tui-test'; + * + * test.use({ shell: Shell.Cmd }); + * + * test('test on cmd', async ({ terminal }) => { + * // The terminal now is running the shell that has been specified + * }); + * ``` + * + * **Details** + * + * `test.use` can be called either in the global scope or inside `test.describe`. It is an error to call it within + * `beforeEach` or `beforeAll`. + * ``` + * + * @param options An object with local options. + */ + test_1.use = (options) => { + globalThis.suite.options = { ...globalThis.suite.options, ...options }; + }; + /** + * Declares a group of tests. + * + * **Usage** + * + * ```js + * test.describe('two tests', () => { + * test('one', async ({ terminal }) => { + * // ... + * }); + * + * test('two', async ({ terminal }) => { + * // ... + * }); + * }); + * ``` + * + * @param title Group title. + * @param callback A callback that is run immediately when calling test.describe + * Any tests added in this callback will belong to the group. + */ + test_1.describe = (title, callback) => { + const parentSuite = globalThis.suite; + const currentSuite = new Suite(title, "describe", parentSuite.options, parentSuite); + parentSuite.suites.push(currentSuite); + globalThis.suite = currentSuite; + callback(); + globalThis.suite = parentSuite; + }; + /** + * Declares a skipped test. Skipped test is never run. + * + * **Usage** + * + * ```js + * import { test, expect } from '@microsoft/tui-test'; + * + * test.skip('broken test', async ({ page }) => { + * // ... + * }); + * ``` + * + * @param title Test title. + * @param testFunction The test function that is run when calling the test function. + */ + test_1.skip = (title, testFunction) => { + const location = getTestLocation(); + const test = new TestCase(title, location, testFunction, globalThis.suite, "skipped"); + if (globalThis.tests != null) { + globalThis.tests[test.id] = test; + } + globalThis.suite.tests.push(test); + }; + /** + * Declares a failed test. + * + * **Usage** + * + * ```js + * import { test, expect } from '@microsoft/tui-test'; + * + * test.fail('purposely failing test', async ({ page }) => { + * // ... + * }); + * ``` + * + * @param title Test title. + * @param testFunction The test function that is run when calling the test function. + */ + test_1.fail = (title, testFunction) => { + const location = getTestLocation(); + const test = new TestCase(title, location, testFunction, globalThis.suite, "unexpected"); + if (globalThis.tests != null) { + globalThis.tests[test.id] = test; + } + globalThis.suite.tests.push(test); + }; + /** + * Declares a conditional test. + * + * **Usage** + * + * ```js + * import { test, expect } from '@microsoft/tui-test'; + * import os from "node:os"; + * + * const isWindows = os.platform() == "win32" + * test.when(isWindows, 'windows only test', async ({ page }) => { + * // ... + * }); + * ``` + * + * @param shouldRun If the test should be run or skipped. + * @param title Test title. + * @param testFunction The test function that is run when calling the test function. + */ + test_1.when = (shouldRun, title, testFunction) => { + if (shouldRun) { + test(title, testFunction); + } + else { + test_1.skip(title, testFunction); + } + }; + /** + * Declares a focused test. If there are some focused tests or suites, all of them will be run but nothing else. + * + * **Usage** + * + * ```js + * import { test, expect } from '@microsoft/tui-test'; + * + * test.only('focus this test', async ({ page }) => { + * // Run only focused tests + * }); + * ``` + * + * @param title Test title. + * @param testFunction The test function that is run when calling the test function. + */ + test_1.only = (title, testFunction) => { + const location = getTestLocation(); + const test = new TestCase(title, location, testFunction, globalThis.suite, "expected", ["only"]); + if (globalThis.tests != null) { + globalThis.tests[test.id] = test; + } + globalThis.suite.tests.push(test); + }; +})(test || (test = {})); +jestExpect.extend({ + toBeVisible, + toMatchSnapshot, + toHaveBgColor, + toHaveFgColor, +}); +const expect = jestExpect; +export { expect }; +/** + * Defines tui-test config + */ +export function defineConfig(config) { + return config; +} diff --git a/lib/test/testcase.d.ts b/lib/test/testcase.d.ts new file mode 100644 index 0000000..875a075 --- /dev/null +++ b/lib/test/testcase.d.ts @@ -0,0 +1,39 @@ +import { Terminal } from "../terminal/term.js"; +import type { Suite } from "./suite.js"; +import type { SnapshotStatus } from "./matchers/toMatchSnapshot.js"; +export type Location = { + row: number; + column: number; +}; +export type TestFunction = (args: { + terminal: Terminal; +}) => void | Promise; +export type TestStatus = "expected" | "unexpected" | "pending" | "skipped" | "flaky"; +export type Snapshot = { + name: string; + result: SnapshotStatus; +}; +export type TestResult = { + status: TestStatus; + error?: string; + duration: number; + snapshots: Snapshot[]; + stdout?: string; + stderr?: string; +}; +export declare class TestCase { + readonly title: string; + readonly location: Location; + readonly testFunction: TestFunction; + readonly suite: Suite; + readonly expectedStatus: TestStatus; + readonly annotations: string[]; + readonly id: string; + readonly results: TestResult[]; + constructor(title: string, location: Location, testFunction: TestFunction, suite: Suite, expectedStatus?: TestStatus, annotations?: string[]); + outcome(): TestStatus; + snapshots(): Snapshot[]; + filePath(): string | undefined; + titlePath(): string[]; + sourcePath(): string | undefined; +} diff --git a/lib/test/testcase.js b/lib/test/testcase.js new file mode 100644 index 0000000..697747d --- /dev/null +++ b/lib/test/testcase.js @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +export class TestCase { + title; + location; + testFunction; + suite; + expectedStatus; + annotations; + id; + results = []; + constructor(title, location, testFunction, suite, expectedStatus = "expected", annotations = []) { + this.title = title; + this.location = location; + this.testFunction = testFunction; + this.suite = suite; + this.expectedStatus = expectedStatus; + this.annotations = annotations; + this.id = this.titlePath().join(""); + } + outcome() { + if (this.results.length == 0 || + this.results.every((result) => result.status === "skipped")) + return "skipped"; + let status = this.results[0].status; + for (const result of this.results.slice(1)) { + if ((status === "unexpected" && result.status === "expected") || + (status === "expected" && result.status !== "expected")) { + return "flaky"; + } + status = result.status; + } + if (this.expectedStatus === status) + return "expected"; + return "unexpected"; + } + snapshots() { + return this.results.at(-1)?.snapshots ?? []; + } + filePath() { + let currentSuite = this.suite; + while (currentSuite != null) { + if (currentSuite.type === "file") { + return currentSuite.title; + } + currentSuite = currentSuite.parentSuite; + } + } + titlePath() { + const titles = []; + let currentSuite = this.suite; + while (currentSuite != null) { + if (currentSuite.type === "project" && currentSuite.title.length != 0) { + titles.push(`[${currentSuite.title}]`); + } + else if (currentSuite.type === "describe") { + titles.push(currentSuite.title); + } + else if (currentSuite.type === "file") { + titles.push(`${currentSuite.title}:${this.location.row}:${this.location.column}`); + } + currentSuite = currentSuite.parentSuite; + } + return [...titles.reverse(), this.title]; + } + sourcePath() { + let currentSuite = this.suite; + while (currentSuite != null) { + if (currentSuite.type === "file") { + return currentSuite.source; + } + currentSuite = currentSuite.parentSuite; + } + } +} diff --git a/lib/trace/tracer.d.ts b/lib/trace/tracer.d.ts new file mode 100644 index 0000000..300c353 --- /dev/null +++ b/lib/trace/tracer.d.ts @@ -0,0 +1,17 @@ +export type TracePoint = DataTracePoint | SizeTracePoint; +export type DataTracePoint = { + time: number; + data: string; +}; +export type SizeTracePoint = { + rows: number; + cols: number; +}; +export type Trace = { + tracePoints: TracePoint[]; + testPath: string[]; + testName: string[]; + attempt: number; +}; +export declare const loadTrace: (traceFilename: string) => Promise; +export declare const saveTrace: (tracePoints: TracePoint[], testId: string, attempt: number, traceFolder: string) => Promise; diff --git a/lib/trace/tracer.js b/lib/trace/tracer.js new file mode 100644 index 0000000..d4ed18f --- /dev/null +++ b/lib/trace/tracer.js @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import path from "node:path"; +import fs from "node:fs"; +import zlib from "node:zlib"; +import process from "node:process"; +import fsAsync from "node:fs/promises"; +import { promisify } from "node:util"; +const zipInflate = promisify(zlib.inflate); +const zipDeflate = promisify(zlib.deflate); +const traceFilename = (testId, attempt) => { + const test = globalThis.tests[testId]; + const filename = path + .relative(process.cwd(), path.resolve(test.filePath())) + .replace(path.sep, "-"); + const title = test.titlePath().slice(1).join("-"); + const retry = attempt > 0 ? `-retry${attempt}` : ""; + const name = `${filename}-${title}${retry}`.replaceAll(/[ /\\<>:"'|?*]/g, "-"); + return name; +}; +const testName = (testId) => { + const test = globalThis.tests[testId]; + const t = test.titlePath(); + const [filename, row, column] = t[0].split(":"); + const testPath = [ + ...path.relative(process.cwd(), path.resolve(filename)).split(path.sep), + row, + column, + ]; + const testName = t.slice(1); + return { testName, testPath }; +}; +export const loadTrace = async (traceFilename) => { + if (!fs.existsSync(traceFilename)) { + throw new Error("unable to load trace, file not found"); + } + return JSON.parse((await zipInflate(await fsAsync.readFile(traceFilename))).toString()); +}; +export const saveTrace = async (tracePoints, testId, attempt, traceFolder) => { + const filename = traceFilename(testId, attempt); + if (!fs.existsSync(traceFolder)) { + await fsAsync.mkdir(traceFolder, { recursive: true }); + } + const trace = { + tracePoints, + attempt, + ...testName(testId), + }; + await fsAsync.writeFile(path.join(traceFolder, filename), await zipDeflate(Buffer.from(JSON.stringify(trace), "utf8"))); +}; diff --git a/lib/trace/viewer.d.ts b/lib/trace/viewer.d.ts new file mode 100644 index 0000000..5246edc --- /dev/null +++ b/lib/trace/viewer.d.ts @@ -0,0 +1,2 @@ +import { Trace } from "./tracer.js"; +export declare const play: (trace: Trace) => Promise; diff --git a/lib/trace/viewer.js b/lib/trace/viewer.js new file mode 100644 index 0000000..46621d1 --- /dev/null +++ b/lib/trace/viewer.js @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import readline from "node:readline"; +import { EventEmitter } from "node:events"; +import chalk from "chalk"; +import ansi from "../terminal/ansi.js"; +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); +const question = (query) => new Promise((resolve) => rl.question(query, (answer) => resolve(answer))); +export const play = async (trace) => { + const startTime = trace.tracePoints.find((tracePoint) => "time" in tracePoint).time; + const startSize = trace.tracePoints.find((tracePoint) => "rows" in tracePoint); + if (process.stdout.columns != startSize.cols || + process.stdout.rows != startSize.rows) { + console.warn(chalk.yellow(`Warning: the current terminal size (rows: ${process.stdout.rows}, columns: ${process.stdout.columns}) doesn't match the starting dimensions used in the trace (rows: ${startSize.rows}, columns: ${startSize.cols}).`)); + } + if (trace.tracePoints.filter((tracePoint) => "rows" in tracePoint).length > 1) { + console.warn(chalk.yellow(`Warning: the trace contains resize actions which won't be emulated when viewing.`)); + } + const answer = (await question("\nDo you want to start the trace [y/N]? ")) + .trim() + .toLowerCase(); + if (answer !== "y" && answer !== "yes") { + process.stdout.write("Exiting trace viewer\n"); + process.exit(0); + } + const dataPoints = trace.tracePoints + .filter((tracePoint) => "time" in tracePoint && tracePoint.data != "") + .map((dataPoint) => ({ ...dataPoint, delay: dataPoint.time - startTime })); + const totalEvents = dataPoints.length; + let executedEvents = 0; + const e = new EventEmitter(); + process.stdout.write(ansi.saveScreen); + process.stdout.write(ansi.clearScreen); + process.stdout.write(ansi.cursorTo(0, 0)); + dataPoints.forEach((dataPoint) => { + setTimeout(() => { + process.stdout.write(dataPoint.data); + e.emit("write"); + }, dataPoint.delay); + }); + e.on("write", async () => { + executedEvents += 1; + if (executedEvents == totalEvents) { + await question("\n\nReplay complete, press any key to exit "); + process.stdout.write(ansi.cursorTo(process.stdout.rows, process.stdout.columns)); + process.stdout.write(ansi.restoreScreen); + process.stdout.write("\n\n"); + process.exit(0); + } + }); + return new Promise(() => { }); +}; diff --git a/lib/utils/constants.d.ts b/lib/utils/constants.d.ts new file mode 100644 index 0000000..c5c7a0a --- /dev/null +++ b/lib/utils/constants.d.ts @@ -0,0 +1,5 @@ +export declare const executableName = "tui-test"; +export declare const programFolderName = ".tui-test"; +export declare const cacheFolderName: string; +export declare const configFileName = "tui-test.config.js"; +export declare const strictModeErrorPrefix = "strict mode violation"; diff --git a/lib/utils/constants.js b/lib/utils/constants.js new file mode 100644 index 0000000..2ad3490 --- /dev/null +++ b/lib/utils/constants.js @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import path from "node:path"; +export const executableName = "tui-test"; +export const programFolderName = ".tui-test"; +export const cacheFolderName = path.join(programFolderName, "cache"); +export const configFileName = "tui-test.config.js"; +export const strictModeErrorPrefix = "strict mode violation"; diff --git a/lib/utils/main.d.ts b/lib/utils/main.d.ts new file mode 100644 index 0000000..4d10af5 --- /dev/null +++ b/lib/utils/main.d.ts @@ -0,0 +1 @@ +export declare const isMain: (meta: ImportMeta) => boolean; diff --git a/lib/utils/main.js b/lib/utils/main.js new file mode 100644 index 0000000..665a37d --- /dev/null +++ b/lib/utils/main.js @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import path from "node:path"; +import process from "node:process"; +import module from "node:module"; +import url from "node:url"; +const stripExt = (name) => { + const extension = path.extname(name); + return !extension ? name : name.slice(0, -extension.length); +}; +export const isMain = (meta) => { + if (!meta || !process.argv[1]) { + return false; + } + const require = module.createRequire(meta.url); + const scriptPath = require.resolve(process.argv[1]); + const modulePath = url.fileURLToPath(meta.url); + const extension = path.extname(scriptPath); + if (extension) { + return modulePath === scriptPath; + } + return stripExt(modulePath) === scriptPath; +}; diff --git a/lib/utils/poll.d.ts b/lib/utils/poll.d.ts new file mode 100644 index 0000000..a791481 --- /dev/null +++ b/lib/utils/poll.d.ts @@ -0,0 +1 @@ +export declare function poll(callback: () => boolean | Promise, delay: number, timeout: number, isNot?: boolean): Promise; diff --git a/lib/utils/poll.js b/lib/utils/poll.js new file mode 100644 index 0000000..485454c --- /dev/null +++ b/lib/utils/poll.js @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +export async function poll(callback, delay, timeout, isNot) { + return await _poll(callback, Date.now(), delay, timeout, isNot ?? false); +} +async function _poll(callback, startTime, delay, timeout, isNot) { + const result = await Promise.resolve(callback()); + if (!isNot && result) { + return true; + } + if (isNot && !result) { + return false; + } + if (startTime + timeout < Date.now()) { + return isNot; + } + return new Promise((resolve) => setTimeout(() => resolve(_poll(callback, startTime, delay, timeout, isNot)), delay)); +} diff --git a/package-lock.json b/package-lock.json index 61b8ab1..8fc9082 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1-rc.5", "license": "MIT", "dependencies": { - "@homebridge/node-pty-prebuilt-multiarch": "^0.11.12", + "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@swc/core": "^1.3.102", "@xterm/headless": "^5.3.0", "chalk": "^5.3.0", @@ -46,7 +46,7 @@ "typescript": "^5.3.3" }, "engines": { - "node": ">=16.6.0 <21.0.0" + "node": ">=20.0.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -602,12 +602,17 @@ } }, "node_modules/@homebridge/node-pty-prebuilt-multiarch": { - "version": "0.11.12", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz", + "integrity": "sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "nan": "^2.18.0", - "prebuild-install": "^7.1.1" + "node-addon-api": "^7.1.0", + "prebuild-install": "^7.1.2" + }, + "engines": { + "node": ">=18.0.0 <25.0.0" } }, "node_modules/@humanwhocodes/config-array": { @@ -1410,6 +1415,8 @@ }, "node_modules/base64-js": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", @@ -1428,6 +1435,8 @@ }, "node_modules/bl": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -1454,6 +1463,8 @@ }, "node_modules/buffer": { "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "funding": [ { "type": "github", @@ -1519,6 +1530,8 @@ }, "node_modules/chownr": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, "node_modules/ci-info": { @@ -1807,6 +1820,8 @@ }, "node_modules/decompress-response": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -1820,6 +1835,8 @@ }, "node_modules/deep-extend": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", "engines": { "node": ">=4.0.0" @@ -1831,7 +1848,9 @@ "license": "MIT" }, "node_modules/detect-libc": { - "version": "2.0.2", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz", + "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==", "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1894,7 +1913,9 @@ "license": "MIT" }, "node_modules/end-of-stream": { - "version": "1.4.4", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -2171,6 +2192,8 @@ }, "node_modules/expand-template": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "license": "(MIT OR WTFPL)", "engines": { "node": ">=6" @@ -2309,6 +2332,8 @@ }, "node_modules/fs-constants": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, "node_modules/fs.realpath": { @@ -2414,6 +2439,8 @@ }, "node_modules/github-from-package": { "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, "node_modules/glob": { @@ -2559,6 +2586,8 @@ }, "node_modules/ieee754": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "funding": [ { "type": "github", @@ -3111,6 +3140,8 @@ }, "node_modules/mimic-response": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", "engines": { "node": ">=10" @@ -3169,6 +3200,8 @@ }, "node_modules/mkdirp-classic": { "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, "node_modules/ms": { @@ -3176,12 +3209,10 @@ "dev": true, "license": "MIT" }, - "node_modules/nan": { - "version": "2.18.0", - "license": "MIT" - }, "node_modules/napi-build-utils": { - "version": "1.0.2", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, "node_modules/natural-compare": { @@ -3190,7 +3221,9 @@ "license": "MIT" }, "node_modules/node-abi": { - "version": "3.54.0", + "version": "3.77.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz", + "integrity": "sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==", "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -3199,6 +3232,12 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/normalize-package-data": { "version": "3.0.3", "dev": true, @@ -3396,7 +3435,9 @@ } }, "node_modules/prebuild-install": { - "version": "7.1.1", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "license": "MIT", "dependencies": { "detect-libc": "^2.0.0", @@ -3404,7 +3445,7 @@ "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", + "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", @@ -3492,7 +3533,9 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, "node_modules/pump": { - "version": "3.0.0", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -3536,6 +3579,8 @@ }, "node_modules/rc": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", @@ -3549,6 +3594,8 @@ }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3913,6 +3960,8 @@ }, "node_modules/simple-concat": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", "funding": [ { "type": "github", @@ -3931,6 +3980,8 @@ }, "node_modules/simple-get": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", "funding": [ { "type": "github", @@ -4145,7 +4196,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.1", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -4156,6 +4209,8 @@ }, "node_modules/tar-stream": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "license": "MIT", "dependencies": { "bl": "^4.0.3", @@ -4270,6 +4325,8 @@ }, "node_modules/tunnel-agent": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" diff --git a/package.json b/package.json index 8fdfc8b..22735de 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "description": "An end-to-end terminal testing framework for CLI and TUI experiences", "type": "module", "engines": { - "node": ">=16.6.0 <21.0.0" + "node": ">=20.0.0" }, "repository": { "type": "git", @@ -32,7 +32,7 @@ "tui-test": "index.js" }, "dependencies": { - "@homebridge/node-pty-prebuilt-multiarch": "^0.11.12", + "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@swc/core": "^1.3.102", "chalk": "^5.3.0", "color-convert": "^2.0.1", diff --git a/src/runner/runner.ts b/src/runner/runner.ts index 28279e5..18eee76 100644 --- a/src/runner/runner.ts +++ b/src/runner/runner.ts @@ -106,7 +106,9 @@ const checkNodeVersion = () => { if ( nodeMajorVersion != "16" && nodeMajorVersion != "18" && - nodeMajorVersion != "20" + nodeMajorVersion != "20" && + nodeMajorVersion != "22" && + nodeMajorVersion != "24" ) { console.warn( chalk.yellow(