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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/adapters/test-collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ import type {TestAdapter} from '../test';

export interface TestCollectionAdapter {
readonly tests: TestAdapter[];
readonly hasFocusedTests?: boolean;
}
15 changes: 11 additions & 4 deletions lib/adapters/test-collection/testplane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ import type {Config, TestCollection} from 'testplane';
export class TestplaneTestCollectionAdapter implements TestCollectionAdapter {
private _testCollection: TestCollection;
private _testAdapters: TestplaneTestAdapter[];
private _hasFocusedTests: boolean;

static create<T>(
this: new (testCollection: TestCollection, saveHistoryMode?: Config['saveHistoryMode']) => T,
this: new (testCollection: TestCollection, saveHistoryMode?: Config['saveHistoryMode'], hasFocusedTests?: boolean) => T,
testCollection: TestCollection,
saveHistoryMode?: Config['saveHistoryMode']
saveHistoryMode?: Config['saveHistoryMode'],
hasFocusedTests = false
): T {
return new this(testCollection, saveHistoryMode);
return new this(testCollection, saveHistoryMode, hasFocusedTests);
}

constructor(testCollection: TestCollection, saveHistoryMode?: Config['saveHistoryMode']) {
constructor(testCollection: TestCollection, saveHistoryMode?: Config['saveHistoryMode'], hasFocusedTests = false) {
this._testCollection = testCollection;
this._hasFocusedTests = hasFocusedTests;

this._testAdapters = this._testCollection.mapTests(test => TestplaneTestAdapter.create(test, saveHistoryMode));
}
Expand All @@ -27,4 +30,8 @@ export class TestplaneTestCollectionAdapter implements TestCollectionAdapter {
get tests(): TestplaneTestAdapter[] {
return this._testAdapters;
}

get hasFocusedTests(): boolean {
return this._hasFocusedTests;
}
}
12 changes: 11 additions & 1 deletion lib/adapters/test/testplane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,17 @@ export class TestplaneTestAdapter implements TestAdapter {
}

get titlePath(): string[] {
return this._test.fullTitle().split(DEFAULT_TITLE_DELIMITER);
const titles: string[] = [];
let current: Test | Suite | null = this._test;

while (current) {
if (current.title) {
titles.unshift(current.title);
}
current = current.parent;
}

return titles.length ? titles : this._test.fullTitle().split(DEFAULT_TITLE_DELIMITER);
}

createTestResult(opts: CreateTestResultOpts): ReporterTestResult {
Expand Down
7 changes: 7 additions & 0 deletions lib/adapters/tool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,22 @@ export interface UpdateReferenceOpts {
state: string;
}

export interface TestsWatchPlan {
paths: string[];
roots: string[];
}

export interface ToolAdapter {
readonly toolName: ToolName;
readonly config: ConfigAdapter;
readonly reporterConfig: ReporterConfig;
readonly htmlReporter: HtmlReporter;
readonly guiApi?: GuiApi;
readonly browserFeatures: Record<string, BrowserFeature[]>;
readonly hasFocusedTestsInLastRead?: boolean;

initGuiApi(): void;
getTestsWatchPlan?(paths: string[], cliTool: CommanderStatic): TestsWatchPlan;
readTests(paths: string[], cliTool: CommanderStatic): Promise<TestCollectionAdapter>;
run(testCollection: TestCollectionAdapter, tests: TestSpec[], cliTool: CommanderStatic): Promise<boolean>;
runWithoutRetries(testCollection: TestCollectionAdapter, tests: TestSpec[], cliTool: CommanderStatic): Promise<boolean>;
Expand Down
98 changes: 95 additions & 3 deletions lib/adapters/tool/testplane/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import _ from 'lodash';
import fs from 'node:fs';
import path from 'node:path';
import type Testplane from 'testplane';
import type {Config} from 'testplane';
import type {CommanderStatic} from '@gemini-testing/commander';
Expand All @@ -15,7 +17,7 @@ import {GuiReportBuilder} from '../../../report-builder/gui';
import {handleTestResults} from './test-results-handler';
import {BrowserFeature, ToolName} from '../../../constants';

import {ToolAdapter, ToolAdapterOptionsFromCli, UpdateReferenceOpts} from '../index';
import {TestsWatchPlan, ToolAdapter, ToolAdapterOptionsFromCli, UpdateReferenceOpts} from '../index';
import type {CustomGuiActionPayload, TestSpec} from '../types';
import type {CustomGuiItem, ReporterConfig} from '../../../types';
import type {ConfigAdapter} from '../../config/index';
Expand Down Expand Up @@ -44,6 +46,37 @@ type RunTestArgs = [TestplaneTestCollectionAdapter, TestSpec[], CommanderStatic]
type Options = ToolAdapterOptionsFromCli | OptionsFromPlugin;

const SUPPORTED_TOOLS = [ToolName.Testplane, 'hermione'];
const DEFAULT_TEST_PATHS = ['testplane', 'hermione'];
const MOCHA_TEST_METHODS = ['describe', 'context', 'it', 'specify', 'suite', 'test'];

type MochaMethod = ((...args: unknown[]) => unknown) & {
only?: (...args: unknown[]) => unknown;
};

const getEnvSets = (): string[] => {
const value = process.env.TESTPLANE_SETS || process.env.HERMIONE_SETS;

return value ? value.split(/, */) : [];
};

const getWatchRoot = (pattern: string): string => {
const normalizedPattern = pattern.replaceAll('\\', '/');
const globStart = normalizedPattern.search(/[!*?()[\]{}]/);

if (globStart !== -1) {
const staticPart = normalizedPattern.slice(0, globStart);

return staticPart.endsWith('/') ? staticPart.slice(0, -1) : path.dirname(staticPart);
}

const normalizedPath = normalizedPattern.replace(/\/$/, '');

try {
return fs.statSync(normalizedPath).isDirectory() ? normalizedPath : path.dirname(normalizedPath);
} catch {
return path.extname(normalizedPath) ? path.dirname(normalizedPath) : normalizedPath;
}
};

export class TestplaneToolAdapter implements ToolAdapter {
private _toolName: ToolName;
Expand All @@ -54,6 +87,7 @@ export class TestplaneToolAdapter implements ToolAdapter {
private _guiApi?: GuiApi;
private _browserConfigs: ReturnType<Config['forBrowser']>[];
private _retryCache: Record<string, number>;
private _hasFocusedTestsInLastRead: boolean;

static create<TestplaneToolAdapter>(
this: new (options: Options) => TestplaneToolAdapter,
Expand Down Expand Up @@ -81,6 +115,7 @@ export class TestplaneToolAdapter implements ToolAdapter {
this._htmlReporter = HtmlReporter.create(this._reporterConfig, {toolName: ToolName.Testplane});

this._retryCache = {};
this._hasFocusedTestsInLastRead = false;

// in order to be able to use it from other plugins as an API
this._tool.htmlReporter = this._htmlReporter;
Expand Down Expand Up @@ -123,21 +158,78 @@ export class TestplaneToolAdapter implements ToolAdapter {
return result;
}

get hasFocusedTestsInLastRead(): boolean {
return this._hasFocusedTestsInLastRead;
}

initGuiApi(): void {
this._guiApi = GuiApi.create();

// in order to be able to use it from other plugins as an API
this._tool.gui = this._guiApi.gui;
}

getTestsWatchPlan(paths: string[], cliTool: CommanderStatic): TestsWatchPlan {
const selectedSets = ([] as string[]).concat(cliTool.set || [], getEnvSets());
const configuredSets = selectedSets.length
? _.pick(this._tool.config.sets, selectedSets)
: this._tool.config.sets;
const configuredPaths = Object.values(configuredSets).flatMap(({files}) => files);
const watchPaths = _.uniq(paths.length ? paths : configuredPaths.length ? configuredPaths : DEFAULT_TEST_PATHS);

return {
paths: watchPaths,
roots: _.uniq(watchPaths.map(getWatchRoot).filter(Boolean))
};
}

async readTests(paths: string[], cliTool: CommanderStatic): Promise<TestplaneTestCollectionAdapter> {
const {TestplaneTestCollectionAdapter} = await import('../../test-collection/testplane');
const {grep, tag, set: sets, browser: browsers} = cliTool;
const replMode = getReplModeOption(cliTool);
this._hasFocusedTestsInLastRead = false;
const wrappedOnlyMethods: Array<{method: MochaMethod; original: MochaMethod['only']; wrapped: MochaMethod['only']}> = [];
const wrappedMethods = new Set<MochaMethod>();
const setFocusedTests = (): void => {
this._hasFocusedTestsInLastRead = true;
};
const markFocusedTests = (): void => {
const mochaGlobals = globalThis as typeof globalThis & Record<string, MochaMethod | undefined>;

for (const methodName of MOCHA_TEST_METHODS) {
const method = mochaGlobals[methodName];
const original = method?.only;

if (!method || !original || wrappedMethods.has(method)) {
continue;
}

const wrapped = function(this: unknown, ...args: unknown[]): unknown {
setFocusedTests();

return original.apply(this, args);
};

method.only = wrapped;
wrappedMethods.add(method);
wrappedOnlyMethods.push({method, original, wrapped});
}
};

const testCollection = await this._tool.readTests(paths, {grep, sets, tag, browsers, replMode});
this._tool.on('beforeFileRead', markFocusedTests);

return TestplaneTestCollectionAdapter.create(testCollection, this._tool.config.saveHistoryMode);
try {
const testCollection = await this._tool.readTests(paths, {grep, sets, tag, browsers, replMode});

return TestplaneTestCollectionAdapter.create(testCollection, this._tool.config.saveHistoryMode, this._hasFocusedTestsInLastRead);
} finally {
this._tool.removeListener('beforeFileRead', markFocusedTests);
for (const {method, original, wrapped} of wrappedOnlyMethods) {
if (method.only === wrapped) {
method.only = original;
}
}
}
}

async run(testCollectionAdapter: TestplaneTestCollectionAdapter, tests: TestSpec[] = [], cliTool: CommanderStatic): Promise<boolean> {
Expand Down
1 change: 1 addition & 0 deletions lib/cli/commands/gui.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = (cliTool, toolAdapter) => {
.option('-p, --port <port>', 'Port to launch server on', 8000)
.option('--hostname <hostname>', 'Hostname to launch server on', 'localhost')
.option('-a, --auto-run', 'auto run immediately')
.option('--watch', 'automatically refresh tests when test files change')
.option('-O, --no-open', 'not to open a browser window after starting the server')
.option('--inspect [inspect]', 'nodejs inspector on [=[host:]port] (Testplane only)')
.option('--inspect-brk [inspect-brk]', 'nodejs inspector with break at the start (Testplane only)')
Expand Down
11 changes: 10 additions & 1 deletion lib/gui/app.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {Response} from 'express';

import {RunParams, ToolRunner, ToolRunnerTree, UndoAcceptImagesResult} from './tool-runner';
import {RunParams, TestsTreeUpdate, ToolRunner, ToolRunnerTree, UndoAcceptImagesResult} from './tool-runner';
import {TestBranch, TestEqualDiffsData, TestRefUpdateData} from '../tests-tree-builder/gui';

import type {ServerArgs} from './index';
Expand Down Expand Up @@ -58,6 +58,15 @@ export class App {
return this._toolRunner.tree;
}

async refreshTestsIfChanged(
changedFiles: string[],
removedDirectories: string[],
onChanged: (changed: boolean) => void,
onUpdated: (update: TestsTreeUpdate) => void
): Promise<void> {
return this._toolRunner.refreshTestsIfChanged(changedFiles, removedDirectories, onChanged, onUpdated);
}

addClient(connection: Response): void {
this._toolRunner.addClient(connection);
}
Expand Down
6 changes: 5 additions & 1 deletion lib/gui/constants/client-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ export const ClientEvents = {

CONNECTED: 'connected',

DOM_SNAPSHOTS: 'DOM_SNAPSHOTS'
DOM_SNAPSHOTS: 'DOM_SNAPSHOTS',

TESTS_REFRESH_STARTED: 'testsRefreshStarted',
TESTS_REFRESHED: 'testsRefreshed',
TESTS_REFRESH_FAILED: 'testsRefreshFailed'
} as const;

export type ClientEvents = typeof ClientEvents;
Expand Down
1 change: 1 addition & 0 deletions lib/gui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {logError} = utils;

export interface GuiCliOptions {
autoRun: boolean;
watch?: boolean;
open: unknown;
port: number;
hostname: string;
Expand Down
13 changes: 13 additions & 0 deletions lib/gui/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {ToolRunnerTree} from './tool-runner';
import type {TestplaneConfigAdapter} from '../adapters/config/testplane';
import type {UpdateTimeTravelSettingsRequest, UpdateTimeTravelSettingsResponse} from '../types';
import chalk from 'chalk';
import {TestsWatcher} from './tests-watcher';

interface CustomGuiError {
response: {
Expand Down Expand Up @@ -277,7 +278,10 @@ export const start = async (args: ServerArgs): Promise<ServerReadyData> => {
}
});

let testsWatcher: TestsWatcher | undefined;

onExit(() => {
testsWatcher?.close();
app.finalize();
logger.log('server shutting down');
});
Expand All @@ -304,6 +308,15 @@ export const start = async (args: ServerArgs): Promise<ServerReadyData> => {

await app.initialize();

if (args.cli.options.watch && toolAdapter.toolName === ToolName.Testplane) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its not the clean architecture i would like to see in html-reporter.
We have a lot of layers in html-reporter (server launches app, which manages tool-runner) to distribute responsibility between the layers, and you just pasted entire watcher in "gui/server.ts".

Now "server.ts" handles chokidar, calculates scope, manages debounce, drain the que, manages this "relatively low" level error handling...

Its clearly should be another module, and not "server.ts" responsibility.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Moved watcher logic to separate TestsWatcher module. Now server.ts only creates and starts it.

const plan = toolAdapter.getTestsWatchPlan?.(args.paths, args.cli.tool);

if (plan) {
testsWatcher = TestsWatcher.create({app, plan, reportPath: reporterConfig.path});
testsWatcher.start();
}
}

const {port: requestedPort, hostname} = args.cli.options;

const {actualPort, hostnameForUrl} = await listenWithFallback({
Expand Down
Loading
Loading