From b166ae494e83eabd79c074688286b2a2ec9c2b98 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Wed, 2 Sep 2026 13:35:13 +0700 Subject: [PATCH 1/4] feat: watch mode --- lib/adapters/config/testplane.ts | 4 ++ lib/cli/commands/gui.js | 1 + lib/gui/app.ts | 4 ++ lib/gui/constants/client-events.ts | 6 +- lib/gui/index.ts | 1 + lib/gui/server.ts | 58 +++++++++++++++++++ lib/gui/tool-runner/index.ts | 22 +++++++ lib/static/new-ui/app/gui.tsx | 15 ++++- .../TreeActionsToolbar/index.module.css | 2 +- package-lock.json | 1 + package.json | 1 + 11 files changed, 112 insertions(+), 3 deletions(-) diff --git a/lib/adapters/config/testplane.ts b/lib/adapters/config/testplane.ts index 0e17620e2..b1985f28f 100644 --- a/lib/adapters/config/testplane.ts +++ b/lib/adapters/config/testplane.ts @@ -29,6 +29,10 @@ export class TestplaneConfigAdapter implements ConfigAdapter { return this._config.forBrowser(browserId); } + getTestFilePatterns(): string[] { + return Object.values(this._config.sets).flatMap(({files}) => files); + } + getScreenshotPath(test: TestplaneTestAdapter, stateName: string): string { const {browserId} = test; diff --git a/lib/cli/commands/gui.js b/lib/cli/commands/gui.js index cf3730f6a..fb7fd5175 100644 --- a/lib/cli/commands/gui.js +++ b/lib/cli/commands/gui.js @@ -16,6 +16,7 @@ module.exports = (cliTool, toolAdapter) => { .option('-p, --port ', 'Port to launch server on', 8000) .option('--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)') diff --git a/lib/gui/app.ts b/lib/gui/app.ts index 2c7ad5c8c..98a4fc60b 100644 --- a/lib/gui/app.ts +++ b/lib/gui/app.ts @@ -58,6 +58,10 @@ export class App { return this._toolRunner.tree; } + async refreshTestsIfChanged(onChanged: () => void): Promise { + return this._toolRunner.refreshTestsIfChanged(onChanged); + } + addClient(connection: Response): void { this._toolRunner.addClient(connection); } diff --git a/lib/gui/constants/client-events.ts b/lib/gui/constants/client-events.ts index 0404c6672..9d8b65be1 100644 --- a/lib/gui/constants/client-events.ts +++ b/lib/gui/constants/client-events.ts @@ -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; diff --git a/lib/gui/index.ts b/lib/gui/index.ts index 548c6716e..2f1a15e14 100644 --- a/lib/gui/index.ts +++ b/lib/gui/index.ts @@ -10,6 +10,7 @@ const {logError} = utils; export interface GuiCliOptions { autoRun: boolean; + watch?: boolean; open: unknown; port: number; hostname: string; diff --git a/lib/gui/server.ts b/lib/gui/server.ts index 45643eab7..891d3a6b5 100644 --- a/lib/gui/server.ts +++ b/lib/gui/server.ts @@ -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 chokidar from 'chokidar'; interface CustomGuiError { response: { @@ -277,7 +278,10 @@ export const start = async (args: ServerArgs): Promise => { } }); + let testsWatcher: chokidar.FSWatcher | undefined; + onExit(() => { + testsWatcher?.close(); app.finalize(); logger.log('server shutting down'); }); @@ -304,6 +308,60 @@ export const start = async (args: ServerArgs): Promise => { await app.initialize(); + if (args.cli.options.watch && toolAdapter.toolName === ToolName.Testplane) { + const config = toolAdapter.config as TestplaneConfigAdapter; + const watchPaths = [...new Set([...config.getTestFilePatterns(), ...args.paths])]; + let refreshInProgress = false; + let refreshPending = false; + let refreshTimer: NodeJS.Timeout | undefined; + + const refresh = async (): Promise => { + if (refreshInProgress) { + refreshPending = true; + return; + } + + refreshInProgress = true; + try { + const changed = await app.refreshTestsIfChanged(() => { + app.sendClientEvent(ClientEvents.TESTS_REFRESH_STARTED, undefined); + }); + + if (changed) { + app.sendClientEvent(ClientEvents.TESTS_REFRESHED, undefined); + } + } catch (error) { + logger.error(`Error while refreshing tests after file change: ${(error as Error).message}`); + } finally { + refreshInProgress = false; + if (refreshPending) { + refreshPending = false; + await refresh(); + } + } + }; + + testsWatcher = chokidar.watch(watchPaths, { + cwd: process.cwd(), + ignoreInitial: true, + ignored: [ + /(^|[/\\])\../, + /(^|[/\\])node_modules([/\\]|$)/, + path.resolve(process.cwd(), reporterConfig.path) + ], + awaitWriteFinish: {stabilityThreshold: 200, pollInterval: 100} + }); + testsWatcher.on('all', () => { + if (refreshTimer) { + clearTimeout(refreshTimer); + } + refreshTimer = setTimeout(() => { + refreshTimer = undefined; + void refresh(); + }, 100); + }); + } + const {port: requestedPort, hostname} = args.cli.options; const {actualPort, hostnameForUrl} = await listenWithFallback({ diff --git a/lib/gui/tool-runner/index.ts b/lib/gui/tool-runner/index.ts index 315ae82e7..33f99b606 100644 --- a/lib/gui/tool-runner/index.ts +++ b/lib/gui/tool-runner/index.ts @@ -165,6 +165,28 @@ export class ToolRunner { await this._fillTestsTree(reportBuilder.buildTreeFromCurrentDb()); } + async refreshTestsIfChanged(onChanged: () => void): Promise { + const collection = await this._readTests(); + const signature = (test: TestAdapter): string => JSON.stringify([test.browserId, test.file, test.titlePath]); + const current = this._ensureTestCollection().tests.map(signature).sort(); + const next = collection.tests.map(signature).sort(); + + if (_.isEqual(current, next)) { + return false; + } + + onChanged(); + + this._collection = collection; + const reportBuilder = this._ensureReportBuilder(); + reportBuilder.resetTree(); + this._testAdapters = {}; + await this._handleRunnableCollection(); + await this._fillTestsTree(reportBuilder.buildTreeFromCurrentDb()); + + return true; + } + protected _ensureReportBuilder(): GuiReportBuilder { if (!this._reportBuilder) { throw new Error('ToolRunner has to be initialized before usage'); diff --git a/lib/static/new-ui/app/gui.tsx b/lib/static/new-ui/app/gui.tsx index fa549541f..e6a9c218a 100644 --- a/lib/static/new-ui/app/gui.tsx +++ b/lib/static/new-ui/app/gui.tsx @@ -10,11 +10,12 @@ import { suiteBegin, testBegin, testResult, - thunkTestsEnd, setRepeatLeft + thunkTestsEnd, setRepeatLeft, setRefreshLoading } from '../../modules/actions'; import {setGuiServerConnectionStatus} from '@/static/modules/actions/gui-server-connection'; import actionNames from '@/static/modules/action-names'; import {EventSourceProvider, useEventSource} from '@/static/new-ui/providers/event-source'; +import {thunkRefreshGuiReport} from '@/static/modules/actions/lifecycle'; const rootEl = document.getElementById('app') as HTMLDivElement; const root = createRoot(rootEl); @@ -66,6 +67,18 @@ function Gui(): ReactNode { const data = JSON.parse(e.data); store.dispatch(setRepeatLeft(data.repeatLeft)); }); + + eventSource.addEventListener(ClientEvents.TESTS_REFRESH_STARTED, () => { + store.dispatch(setRefreshLoading(true)); + }); + + eventSource.addEventListener(ClientEvents.TESTS_REFRESHED, () => { + store.dispatch(thunkRefreshGuiReport()); + }); + + eventSource.addEventListener(ClientEvents.TESTS_REFRESH_FAILED, () => { + store.dispatch(setRefreshLoading(false)); + }); }; useEffect(() => { diff --git a/lib/static/new-ui/components/TreeActionsToolbar/index.module.css b/lib/static/new-ui/components/TreeActionsToolbar/index.module.css index cfb0e2f92..d7a1c6aff 100644 --- a/lib/static/new-ui/components/TreeActionsToolbar/index.module.css +++ b/lib/static/new-ui/components/TreeActionsToolbar/index.module.css @@ -24,7 +24,7 @@ to { transform: rotate(360deg); } } -.is-refresh-loading { +.is-refresh-tests-loading { animation: spin 0.8s linear infinite; } diff --git a/package-lock.json b/package-lock.json index 9f0b141b2..9f69f0d5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "bluebird": "^3.5.3", "body-parser": "^1.18.2", "chalk": "^4.1.2", + "chokidar": "^3.5.3", "debug": "^4.1.1", "escape-html": "^1.0.3", "eventemitter2": "6.4.7", diff --git a/package.json b/package.json index 678c4dbea..926a981eb 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "bluebird": "^3.5.3", "body-parser": "^1.18.2", "chalk": "^4.1.2", + "chokidar": "^3.5.3", "debug": "^4.1.1", "escape-html": "^1.0.3", "eventemitter2": "6.4.7", From 90c08dca77bf3e26c3ff23f85409815e4d6a5a6c Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Mon, 7 Sep 2026 03:10:19 +0700 Subject: [PATCH 2/4] feat: watch mode fixes --- lib/adapters/test/testplane.ts | 12 +- lib/gui/app.ts | 11 +- lib/gui/server.ts | 100 +++++- lib/gui/tool-runner/index.ts | 296 ++++++++++++++++-- lib/report-builder/gui.ts | 25 +- lib/sqlite-client.ts | 28 ++ lib/static/modules/action-names.ts | 1 + lib/static/modules/actions/lifecycle.ts | 8 +- .../reducers/new-ui-grouped-tests/index.ts | 27 ++ lib/static/modules/reducers/tree/index.js | 112 ++++++- .../modules/reducers/tree/nodes/suites.js | 13 + lib/static/modules/search/index.ts | 56 +++- lib/static/modules/search/worker.ts | 42 ++- lib/static/new-ui/app/gui.tsx | 53 +++- .../TreeActionsToolbar/index.module.css | 2 +- .../suites/components/SuitesPage/selectors.ts | 142 ++++++++- lib/tests-tree-builder/base.ts | 2 +- lib/tests-tree-builder/gui.ts | 74 ++++- lib/tests-tree-builder/tree-patch.ts | 103 ++++++ package-lock.json | 16 +- test/unit/lib/adapters/test/testplane.ts | 14 + test/unit/lib/gui/tool-runner/index.js | 88 ++++++ test/unit/lib/sqlite-client.js | 20 ++ .../lib/static/modules/reducers/tree/index.js | 41 +++ .../suites/components/SuitesPage/selectors.js | 66 ++++ test/unit/lib/tests-tree-builder/gui.js | 59 ++++ .../unit/lib/tests-tree-builder/tree-patch.js | 61 ++++ 27 files changed, 1390 insertions(+), 82 deletions(-) create mode 100644 lib/tests-tree-builder/tree-patch.ts create mode 100644 test/unit/lib/static/new-ui/features/suites/components/SuitesPage/selectors.js create mode 100644 test/unit/lib/tests-tree-builder/tree-patch.js diff --git a/lib/adapters/test/testplane.ts b/lib/adapters/test/testplane.ts index c477d628e..eaa194bae 100644 --- a/lib/adapters/test/testplane.ts +++ b/lib/adapters/test/testplane.ts @@ -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 { diff --git a/lib/gui/app.ts b/lib/gui/app.ts index 98a4fc60b..880537b07 100644 --- a/lib/gui/app.ts +++ b/lib/gui/app.ts @@ -5,6 +5,7 @@ import {TestBranch, TestEqualDiffsData, TestRefUpdateData} from '../tests-tree-b import type {ServerArgs} from './index'; import type {TestSpec} from '../adapters/tool/types'; +import type {TreePatch} from '../tests-tree-builder/tree-patch'; export class App { private _toolRunner: ToolRunner; @@ -58,8 +59,14 @@ export class App { return this._toolRunner.tree; } - async refreshTestsIfChanged(onChanged: () => void): Promise { - return this._toolRunner.refreshTestsIfChanged(onChanged); + async refreshTestsIfChanged( + changedFiles: string[], + removedDirectories: string[], + onChanged: (changed: boolean) => void, + onUpdated: (patch: TreePatch) => void, + performanceId: number + ): Promise { + return this._toolRunner.refreshTestsIfChanged(changedFiles, removedDirectories, onChanged, onUpdated, performanceId); } addClient(connection: Response): void { diff --git a/lib/gui/server.ts b/lib/gui/server.ts index 891d3a6b5..2c5782c38 100644 --- a/lib/gui/server.ts +++ b/lib/gui/server.ts @@ -1,4 +1,5 @@ import path from 'path'; +import {performance} from 'node:perf_hooks'; import express from 'express'; import {onExit} from 'signal-exit'; import bodyParser from 'body-parser'; @@ -19,6 +20,7 @@ import type {TestplaneToolAdapter} from '../adapters/tool/testplane'; import type {ToolRunnerTree} from './tool-runner'; import type {TestplaneConfigAdapter} from '../adapters/config/testplane'; import type {UpdateTimeTravelSettingsRequest, UpdateTimeTravelSettingsResponse} from '../types'; +import type {TreePatch} from '../tests-tree-builder/tree-patch'; import chalk from 'chalk'; import chokidar from 'chokidar'; @@ -33,6 +35,14 @@ type TimeTravelConfig = Config['timeTravel']; const originalBrowserConfigs = new Map(); +const getWatchRoot = (pattern: string): string => { + const normalizedPattern = pattern.replaceAll('\\', '/'); + const globStart = normalizedPattern.search(/[!*?()[\]{}]/); + const staticPart = globStart === -1 ? normalizedPattern : normalizedPattern.slice(0, globStart); + + return staticPart.endsWith('/') ? staticPart.slice(0, -1) : path.dirname(staticPart); +}; + export type GetInitResponse = (ToolRunnerTree & {customGuiError?: CustomGuiError} & { browserFeatures: Record, features: Feature[]}) | null; export const start = async (args: ServerArgs): Promise => { @@ -279,9 +289,11 @@ export const start = async (args: ServerArgs): Promise => { }); let testsWatcher: chokidar.FSWatcher | undefined; + let testDirectoriesWatcher: chokidar.FSWatcher | undefined; onExit(() => { testsWatcher?.close(); + testDirectoriesWatcher?.close(); app.finalize(); logger.log('server shutting down'); }); @@ -311,33 +323,63 @@ export const start = async (args: ServerArgs): Promise => { if (args.cli.options.watch && toolAdapter.toolName === ToolName.Testplane) { const config = toolAdapter.config as TestplaneConfigAdapter; const watchPaths = [...new Set([...config.getTestFilePatterns(), ...args.paths])]; + const watchRoots = [...new Set(watchPaths.map(getWatchRoot).filter(Boolean))]; let refreshInProgress = false; - let refreshPending = false; + const queuedFiles = new Set(); + const queuedRemovedDirectories = new Set(); + const debounceFiles = new Set(); + const debounceRemovedDirectories = new Set(); let refreshTimer: NodeJS.Timeout | undefined; + let refreshSequence = 0; + let firstDebouncedEventAt: number | undefined; + + const refresh = async (changedFiles: string[], removedDirectories: string[]): Promise => { + changedFiles.forEach(file => queuedFiles.add(path.resolve(process.cwd(), file))); + removedDirectories.forEach(directory => queuedRemovedDirectories.add(path.resolve(process.cwd(), directory))); - const refresh = async (): Promise => { if (refreshInProgress) { - refreshPending = true; return; } refreshInProgress = true; try { - const changed = await app.refreshTestsIfChanged(() => { - app.sendClientEvent(ClientEvents.TESTS_REFRESH_STARTED, undefined); - }); - - if (changed) { - app.sendClientEvent(ClientEvents.TESTS_REFRESHED, undefined); + while (queuedFiles.size) { + const refreshId = ++refreshSequence; + const serverStartedAt = Date.now(); + const refreshStartedAt = performance.now(); + const files = [...queuedFiles]; + const removedDirs = [...queuedRemovedDirectories]; + queuedFiles.clear(); + queuedRemovedDirectories.clear(); + let changed = false; + let treePatch: TreePatch | undefined; + logger.log(`[watch-perf][server][#${refreshId}] refresh started ${JSON.stringify({files: files.length, removedDirectories: removedDirs.length})}`); + await app.refreshTestsIfChanged(files, removedDirs, (hasChanges) => { + changed = hasChanges; + if (hasChanges) { + app.sendClientEvent(ClientEvents.TESTS_REFRESH_STARTED, {performanceId: refreshId}); + } + }, (patch) => { + treePatch = patch; + }, refreshId); + + if (changed && treePatch) { + treePatch.performance = { + id: refreshId, + serverStartedAt, + serverCompletedAt: Date.now() + }; + const sendStartedAt = performance.now(); + app.sendClientEvent(ClientEvents.TESTS_REFRESHED, treePatch); + logger.log(`[watch-perf][server][#${refreshId}] serialize/write SSE: ${(performance.now() - sendStartedAt).toFixed(1)}ms`); + } + logger.log(`[watch-perf][server][#${refreshId}] refresh loop total: ${(performance.now() - refreshStartedAt).toFixed(1)}ms ${JSON.stringify({changed})}`); } } catch (error) { + app.sendClientEvent(ClientEvents.TESTS_REFRESH_FAILED, undefined); logger.error(`Error while refreshing tests after file change: ${(error as Error).message}`); } finally { refreshInProgress = false; - if (refreshPending) { - refreshPending = false; - await refresh(); - } } }; @@ -351,14 +393,42 @@ export const start = async (args: ServerArgs): Promise => { ], awaitWriteFinish: {stabilityThreshold: 200, pollInterval: 100} }); - testsWatcher.on('all', () => { + const queueFileSystemEvent = (event: string, changedFile: string): void => { + logger.log(`[watch-perf][server] chokidar event ${JSON.stringify({event, path: changedFile})}`); + if (debounceFiles.size === 0) { + firstDebouncedEventAt = performance.now(); + } + debounceFiles.add(changedFile); + if (event === 'unlinkDir') { + debounceRemovedDirectories.add(changedFile); + } if (refreshTimer) { clearTimeout(refreshTimer); } refreshTimer = setTimeout(() => { refreshTimer = undefined; - void refresh(); + logger.log(`[watch-perf][server] chokidar debounce: ${firstDebouncedEventAt === undefined ? 0 : (performance.now() - firstDebouncedEventAt).toFixed(1)}ms ${JSON.stringify({events: debounceFiles.size})}`); + firstDebouncedEventAt = undefined; + const changedFiles = [...debounceFiles]; + const removedDirectories = [...debounceRemovedDirectories]; + debounceFiles.clear(); + debounceRemovedDirectories.clear(); + void refresh(changedFiles, removedDirectories); }, 100); + }; + + testsWatcher.on('all', queueFileSystemEvent); + + // A file glob does not necessarily subscribe Chokidar to directory + // lifecycle events. Watch the non-glob roots separately so deleting a + // directory is always observable. + testDirectoriesWatcher = chokidar.watch(watchRoots, { + cwd: process.cwd(), + ignoreInitial: true, + ignored: [/(^|[/\\])\../, /(^|[/\\])node_modules([/\\]|$)/] + }); + testDirectoriesWatcher.on('unlinkDir', changedDirectory => { + queueFileSystemEvent('unlinkDir', changedDirectory); }); } diff --git a/lib/gui/tool-runner/index.ts b/lib/gui/tool-runner/index.ts index 33f99b606..8bbd1d520 100644 --- a/lib/gui/tool-runner/index.ts +++ b/lib/gui/tool-runner/index.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import os from 'node:os'; +import {performance} from 'node:perf_hooks'; import {CommanderStatic} from '@gemini-testing/commander'; import chalk from 'chalk'; @@ -27,6 +28,7 @@ import { ToolName, DATABASE_URLS_JSON_NAME, LOCAL_DATABASE_NAME, + DEFAULT_TITLE_DELIMITER, PluginEvents, UNKNOWN_ATTEMPT, BrowserFeature, Feature, TimeTravelFeature } from '../../constants'; @@ -36,6 +38,7 @@ import type {GuiCliOptions, ServerArgs} from '../index'; import type {TestBranch, TestEqualDiffsData, TestRefUpdateData} from '../../tests-tree-builder/gui'; import type {ReporterTestResult} from '../../adapters/test-result'; import type {Tree, TreeImage} from '../../tests-tree-builder/base'; +import {createTreePatch, snapshotTree, TreePatch, TreePatchScope} from '../../tests-tree-builder/tree-patch'; import type {TestSpec} from '../../adapters/tool/types'; import type { AssertViewResult, @@ -61,6 +64,16 @@ export interface RunParams { retry?: boolean; } +const logWatchPerformance = (id: number, operation: string, startedAt: number, details?: Record): void => { + const duration = (performance.now() - startedAt).toFixed(1); + const detailsText = details ? ` ${JSON.stringify(details)}` : ''; + + logger.log(`[watch-perf][server][#${id}] ${operation}: ${duration}ms${detailsText}`); +}; + +const isNoTestsFoundError = (error: unknown): boolean => + error instanceof Error && error.message.startsWith('There are no tests found'); + export class ToolRunner { private _testFiles: string[]; private _toolAdapter: ToolAdapter; @@ -73,6 +86,10 @@ export class ToolRunner { private _eventSource: EventSource; protected _reportBuilder: GuiReportBuilder | null; private _testAdapters: Record; + private _testsByFile: Map; + private _testFileBySpec: Map; + private _testAdapterIdsByFile: Map>; + private _collectionNeedsFullRead: boolean; private _expectedImagesCache: Cache<[TestSpecByPath, string | undefined], string>; static create(this: new (args: ServerArgs) => T, args: ServerArgs): T { @@ -95,6 +112,10 @@ export class ToolRunner { this._reportBuilder = null; this._testAdapters = {}; + this._testsByFile = new Map(); + this._testFileBySpec = new Map(); + this._testAdapterIdsByFile = new Map(); + this._collectionNeedsFullRead = false; this._expectedImagesCache = new Cache(getExpectedCacheKey); } @@ -141,7 +162,7 @@ export class ToolRunner { }); this._toolAdapter.handleTestResults(this._reportBuilder, this._eventSource); - this._collection = await this._readTests(); + this._setCollection(await this._readTests()); this._toolAdapter.htmlReporter.emit(PluginEvents.DATABASE_CREATED, dbClient.getRawConnection()); await this._reportBuilder.saveStaticFiles(); @@ -155,36 +176,248 @@ export class ToolRunner { } async refreshTests(): Promise { - this._collection = await this._readTests(); + this._setCollection(await this._readTests()); const reportBuilder = this._ensureReportBuilder(); reportBuilder.resetTree(); this._testAdapters = {}; + this._testAdapterIdsByFile.clear(); await this._handleRunnableCollection(); await this._fillTestsTree(reportBuilder.buildTreeFromCurrentDb()); } - async refreshTestsIfChanged(onChanged: () => void): Promise { - const collection = await this._readTests(); - const signature = (test: TestAdapter): string => JSON.stringify([test.browserId, test.file, test.titlePath]); - const current = this._ensureTestCollection().tests.map(signature).sort(); - const next = collection.tests.map(signature).sort(); + async refreshTestsIfChanged( + changedFiles: string[], + removedDirectories: string[], + onChanged: (changed: boolean) => void, + onUpdated: (patch: TreePatch) => void, + performanceId: number + ): Promise { + const totalStartedAt = performance.now(); + let stageStartedAt = performance.now(); + const normalizedFiles = new Set(changedFiles.map(file => path.resolve(file))); + const normalizedDirectories = removedDirectories.map(directory => path.resolve(directory)); + const isInsideRemovedDirectory = (file: string): boolean => normalizedDirectories.some(directory => { + const relativePath = path.relative(directory, file); + return relativePath !== '' && !relativePath.startsWith(`..${path.sep}`) && relativePath !== '..' && !path.isAbsolute(relativePath); + }); + const affectedFiles = new Set(normalizedFiles); + for (const testFile of this._testsByFile.keys()) { + if (isInsideRemovedDirectory(testFile)) { + affectedFiles.add(testFile); + } + } + const isChangedFile = (test: TestAdapter): boolean => affectedFiles.has(path.resolve(test.file)); + const signature = (test: TestAdapter): string => JSON.stringify([test.browserId, path.resolve(test.file), test.titlePath]); + const currentTests = [...affectedFiles].flatMap(file => this._testsByFile.get(file) ?? []); + const current = currentTests.map(signature).sort(); + logWatchPerformance(performanceId, 'prepare affected files and current signatures', stageStartedAt, { + changedFiles: changedFiles.length, + removedDirectories: removedDirectories.length, + affectedFiles: affectedFiles.size, + currentTests: current.length + }); - if (_.isEqual(current, next)) { - return false; + stageStartedAt = performance.now(); + const existingFiles = await Promise.all(changedFiles.map(async file => await fs.pathExists(file) ? file : null)); + const filesToRead = existingFiles.filter((file): file is string => Boolean(file)); + logWatchPerformance(performanceId, 'check changed files existence', stageStartedAt, {filesToRead: filesToRead.length}); + let next: string[] = []; + let changedCollection: TestCollectionAdapter = {tests: []}; + + if (filesToRead.length) { + try { + stageStartedAt = performance.now(); + changedCollection = await this._toolAdapter.readTests(filesToRead, this._globalOpts); + logWatchPerformance(performanceId, 'read changed files', stageStartedAt, {tests: changedCollection.tests.length}); + + stageStartedAt = performance.now(); + next = changedCollection.tests.filter(isChangedFile).map(signature).sort(); + logWatchPerformance(performanceId, 'build changed files signatures', stageStartedAt, {tests: next.length}); + } catch (error) { + if (isNoTestsFoundError(error)) { + // Testplane throws instead of returning an empty collection + // when the changed file no longer contains any tests. + logWatchPerformance(performanceId, 'read changed files (no tests found)', stageStartedAt, {tests: 0}); + } else { + // If a partial read fails for another reason, use the full + // collection to determine whether the tree has changed. + stageStartedAt = performance.now(); + const collection = await this._readTests(); + logWatchPerformance(performanceId, 'fallback: read all tests', stageStartedAt, {tests: collection.tests.length}); + + stageStartedAt = performance.now(); + const allCurrent = this._ensureTestCollection().tests.map(test => + JSON.stringify([test.browserId, path.resolve(test.file), test.titlePath]) + ).sort(); + const allNext = collection.tests.map(test => + JSON.stringify([test.browserId, path.resolve(test.file), test.titlePath]) + ).sort(); + logWatchPerformance(performanceId, 'fallback: compare all signatures', stageStartedAt, {current: allCurrent.length, next: allNext.length}); + + if (_.isEqual(allCurrent, allNext)) { + this._collectionNeedsFullRead = true; + onChanged(false); + logWatchPerformance(performanceId, 'total (no structural changes)', totalStartedAt); + return; + } + + onChanged(true); + this._setCollection(collection); + const testsToAdd = collection.tests.filter(isChangedFile); + onUpdated(await this._applyChangedFiles(affectedFiles, currentTests, testsToAdd, performanceId)); + logWatchPerformance(performanceId, 'total', totalStartedAt); + + return; + } + } } - onChanged(); + if (!removedDirectories.length && _.isEqual(current, next)) { + this._collectionNeedsFullRead = true; + onChanged(false); + logWatchPerformance(performanceId, 'total (no structural changes)', totalStartedAt); + return; + } - this._collection = collection; + onChanged(true); + stageStartedAt = performance.now(); + const testsToAdd = changedCollection.tests.filter(isChangedFile); + this._replaceTestsInCollection(affectedFiles, testsToAdd); + logWatchPerformance(performanceId, 'merge changed tests into collection', stageStartedAt, { + changedTests: testsToAdd.length, + totalTests: this._ensureTestCollection().tests.length + }); + onUpdated(await this._applyChangedFiles(affectedFiles, currentTests, testsToAdd, performanceId)); + logWatchPerformance(performanceId, 'total', totalStartedAt); + } + + private async _applyChangedFiles( + changedFiles: Set, + previousTests: TestAdapter[], + testsToAdd: TestAdapter[], + performanceId: number + ): Promise { + let stageStartedAt = performance.now(); const reportBuilder = this._ensureReportBuilder(); - reportBuilder.resetTree(); - this._testAdapters = {}; - await this._handleRunnableCollection(); - await this._fillTestsTree(reportBuilder.buildTreeFromCurrentDb()); + const patchScope = this._createTreePatchScope([...previousTests, ...testsToAdd], reportBuilder.testsTree); + const previousTree = snapshotTree(reportBuilder.testsTree, patchScope); + logWatchPerformance(performanceId, 'snapshot previous server tree', stageStartedAt); + + stageStartedAt = performance.now(); + reportBuilder.removeTestsByFiles([...changedFiles]); + logWatchPerformance(performanceId, 'remove affected tests from server tree', stageStartedAt, {files: changedFiles.size}); + + stageStartedAt = performance.now(); + for (const changedFile of changedFiles) { + for (const testId of this._testAdapterIdsByFile.get(changedFile) ?? []) { + delete this._testAdapters[testId]; + } + this._testAdapterIdsByFile.delete(changedFile); + } + logWatchPerformance(performanceId, 'remove affected test adapters', stageStartedAt, {testsToAdd: testsToAdd.length}); + + stageStartedAt = performance.now(); + await this._addTestsToTree(testsToAdd); + logWatchPerformance(performanceId, 'add affected tests to server tree', stageStartedAt); + + stageStartedAt = performance.now(); + reportBuilder.restoreTestHistory(testsToAdd.map(test => ({ + suitePath: test.titlePath, + browserId: test.browserId + }))); + logWatchPerformance(performanceId, 'restore affected tests history', stageStartedAt); + + stageStartedAt = performance.now(); + this._extendTreePatchScope(patchScope, testsToAdd, reportBuilder.testsTree); + const patch = createTreePatch(previousTree, reportBuilder.testsTree, patchScope); + logWatchPerformance(performanceId, 'create tree patch', stageStartedAt, { + suites: Object.keys(patch.suites.byId).length, + browsers: Object.keys(patch.browsers.byId).length, + results: Object.keys(patch.results.byId).length, + images: Object.keys(patch.images.byId).length + }); + + return patch; + } + + private _createTreePatchScope(tests: TestAdapter[], tree: Tree): TreePatchScope { + const scope: TreePatchScope = { + suites: new Set(), + browsers: new Set(), + results: new Set(), + images: new Set() + }; - return true; + this._extendTreePatchScope(scope, tests, tree); + + return scope; + } + + private _extendTreePatchScope(scope: TreePatchScope, tests: TestAdapter[], tree: Tree): void { + for (const test of tests) { + for (let depth = 1; depth <= test.titlePath.length; depth++) { + scope.suites.add(test.titlePath.slice(0, depth).join(DEFAULT_TITLE_DELIMITER)); + } + + const suiteId = test.titlePath.join(DEFAULT_TITLE_DELIMITER); + const browserId = [suiteId, test.browserId].join(DEFAULT_TITLE_DELIMITER); + const browser = tree.browsers.byId[browserId]; + scope.browsers.add(browserId); + + for (const resultId of browser?.resultIds ?? []) { + scope.results.add(resultId); + tree.results.byId[resultId]?.imageIds.forEach(imageId => scope.images.add(imageId)); + } + } + } + + private _setCollection(collection: TestCollectionAdapter): void { + this._collection = collection; + this._collectionNeedsFullRead = false; + this._testsByFile.clear(); + this._testFileBySpec.clear(); + + for (const test of collection.tests) { + if (!test.file) { + continue; + } + const testFile = path.resolve(test.file); + const tests = this._testsByFile.get(testFile) ?? []; + + tests.push(test); + this._testsByFile.set(testFile, tests); + this._testFileBySpec.set(this._getTestSpecKey(test.browserId, test.fullName), testFile); + } + } + + private _replaceTestsInCollection(affectedFiles: Set, testsToAdd: TestAdapter[]): void { + affectedFiles.forEach(file => { + for (const test of this._testsByFile.get(file) ?? []) { + this._testFileBySpec.delete(this._getTestSpecKey(test.browserId, test.fullName)); + } + this._testsByFile.delete(file); + }); + + for (const test of testsToAdd) { + if (!test.file) { + continue; + } + const testFile = path.resolve(test.file); + const tests = this._testsByFile.get(testFile) ?? []; + + tests.push(test); + this._testsByFile.set(testFile, tests); + this._testFileBySpec.set(this._getTestSpecKey(test.browserId, test.fullName), testFile); + } + + this._collection = {tests: [...this._testsByFile.values()].flat()}; + this._collectionNeedsFullRead = true; + } + + private _getTestSpecKey(browserId: string, fullName: string): string { + return JSON.stringify([browserId, fullName]); } protected _ensureReportBuilder(): GuiReportBuilder { @@ -375,7 +608,22 @@ export class ToolRunner { } async run(tests: TestSpec[] = [], runParams: RunParams = {retry: true}): Promise { - const testCollection = this._ensureTestCollection(); + let testCollection = this._ensureTestCollection(); + + if (this._collectionNeedsFullRead) { + const startedAt = performance.now(); + const selectedTestFiles = tests.length + ? _.uniq(tests.map(test => this._testFileBySpec.get(this._getTestSpecKey(test.browserName, test.testName))).filter((file): file is string => Boolean(file))) + : this._testFiles; + const testFiles = tests.length && !selectedTestFiles.length ? this._testFiles : selectedTestFiles; + + testCollection = await this._toolAdapter.readTests(testFiles, this._globalOpts); + if (!tests.length) { + this._setCollection(testCollection); + } + logger.log(`[watch-perf][server][run] refresh executable test collection: ${(performance.now() - startedAt).toFixed(1)}ms ${JSON.stringify({files: testFiles.length, tests: testCollection.tests.length})}`); + } + const shouldRunAllTests = _.isEmpty(tests); // if tests are not passed, then run all tests with all available retries @@ -386,10 +634,15 @@ export class ToolRunner { } protected async _handleRunnableCollection(): Promise { + await this._addTestsToTree(this._ensureTestCollection().tests); + await this._fillTestsTree(); + } + + private async _addTestsToTree(tests: TestAdapter[]): Promise { const reportBuilder = this._ensureReportBuilder(); const queue = new PQueue({concurrency: os.cpus().length}); - for (const test of this._ensureTestCollection().tests) { + for (const test of tests) { if (test.disabled || test.silentlySkipped) { continue; } @@ -397,6 +650,12 @@ export class ToolRunner { // TODO: remove toString after publish major version const testId = formatId(test.id.toString(), test.browserId); this._testAdapters[testId] = test; + if (test.file) { + const testFile = path.resolve(test.file); + const adapterIds = this._testAdapterIdsByFile.get(testFile) ?? new Set(); + adapterIds.add(testId); + this._testAdapterIdsByFile.set(testFile, adapterIds); + } if (test.pending) { queue.add(async () => reportBuilder.addTestResult(test.createTestResult({status: SKIPPED, duration: 0}))); @@ -406,7 +665,6 @@ export class ToolRunner { } await queue.onIdle(); - await this._fillTestsTree(); } protected _getTestAdapterById(updateData: TestRefUpdateData): TestAdapter { diff --git a/lib/report-builder/gui.ts b/lib/report-builder/gui.ts index 39cd9721d..e5affc7cf 100644 --- a/lib/report-builder/gui.ts +++ b/lib/report-builder/gui.ts @@ -10,6 +10,7 @@ import {determineStatus, isUpdatedStatus} from '../common-utils'; import {HtmlReporterValues} from '../plugin-api'; import {StaticTestsTreeBuilder, SkipItem} from '../tests-tree-builder/static'; import {copyAndUpdate} from '../adapters/test-result/utils'; +import type {TestHistorySpec} from '../sqlite-client'; interface UndoAcceptImageResult { updatedImage: TreeImage | undefined; @@ -46,8 +47,8 @@ export class GuiReportBuilder extends StaticReportBuilder { return this; } - reuseTestsTree(tree: Tree): void { - this._testsTree.reuseTestsTree(tree); + reuseTestsTree(tree: Tree, options?: {replaceCurrentResults?: boolean}): void { + this._testsTree.reuseTestsTree(tree, options); // Fill test attempt manager with data from db for (const [, testResult] of Object.entries(tree.results.byId)) { @@ -82,6 +83,26 @@ export class GuiReportBuilder extends StaticReportBuilder { this.resetAttemps(); } + get testsTree(): Tree { + return this._testsTree.tree; + } + + removeTestsByFiles(files: string[]): void { + this._testsTree.removeTestsByFiles(files); + } + + restoreTestHistory(tests: TestHistorySpec[]): void { + const rows = this._dbClient.getSuitesByTests(tests); + if (!rows.length) { + return; + } + + const testsTreeBuilder = StaticTestsTreeBuilder.create({baseHost: this._reporterConfig.baseHost}); + const {tree} = testsTreeBuilder.build(rows); + + this.reuseTestsTree(tree, {replaceCurrentResults: true}); + } + buildTreeFromCurrentDb(): Tree { const testsTreeBuilder = StaticTestsTreeBuilder.create({baseHost: this._reporterConfig.baseHost}); const {tree} = testsTreeBuilder.build(this._dbClient.getAllSuites()); diff --git a/lib/sqlite-client.ts b/lib/sqlite-client.ts index 421ac98be..b4ed355d1 100644 --- a/lib/sqlite-client.ts +++ b/lib/sqlite-client.ts @@ -2,6 +2,7 @@ import path from 'path'; import type {Database, Statement} from '@gemini-testing/sql.js'; import makeDebug from 'debug'; import fs from 'fs-extra'; +import _ from 'lodash'; import NestedError from 'nested-error-stacks'; import {getShortMD5} from './common-utils'; @@ -29,6 +30,11 @@ interface DeleteParams { where?: string; } +export interface TestHistorySpec { + suitePath: string[]; + browserId: string; +} + export interface DbTestResult { description?: string | null; error?: TestError; @@ -172,6 +178,28 @@ export class SqliteClient { return rows.sort(compareDatabaseRowsByTimestamp); } + getSuitesByTests(tests: TestHistorySpec[]): RawSuitesRow[] { + const rows: RawSuitesRow[] = []; + const uniqueTests = _.uniqBy(tests, ({suitePath, browserId}) => `${JSON.stringify(suitePath)}\0${browserId}`); + + for (const {suitePath, browserId} of uniqueTests) { + const statement = this._db.prepare( + `SELECT * FROM ${DB_SUITES_TABLE_NAME} WHERE suitePath = ? AND name = ?` + ); + statement.bind([JSON.stringify(suitePath), browserId]); + + while (statement.step()) { + const row = statement.get(); + if (Array.isArray(row)) { + rows.push(row as RawSuitesRow); + } + } + statement.free(); + } + + return rows.sort(compareDatabaseRowsByTimestamp); + } + write(testResult: ReporterTestResult): void { const dbTestResult = this._transformer.transform(testResult); const values = this._createValuesArray(dbTestResult); diff --git a/lib/static/modules/action-names.ts b/lib/static/modules/action-names.ts index 48d8f5cc5..7611c5ac0 100644 --- a/lib/static/modules/action-names.ts +++ b/lib/static/modules/action-names.ts @@ -1,6 +1,7 @@ export default { INIT_GUI_REPORT: 'INIT_GUI_REPORT', INIT_STATIC_REPORT: 'INIT_STATIC_REPORT', + PATCH_TESTS_TREE: 'PATCH_TESTS_TREE', FIN_GUI_REPORT: 'FIN_GUI_REPORT', FIN_STATIC_REPORT: 'FIN_STATIC_REPORT', RUN_ALL_TESTS: 'RUN_ALL_TESTS', diff --git a/lib/static/modules/actions/lifecycle.ts b/lib/static/modules/actions/lifecycle.ts index 552b9058f..4eaa6561c 100644 --- a/lib/static/modules/actions/lifecycle.ts +++ b/lib/static/modules/actions/lifecycle.ts @@ -23,11 +23,16 @@ import {LocalStorageKey} from '@/constants/local-storage'; import * as localStorageWrapper from '@/static/modules/local-storage-wrapper'; import {updateTimeTravelSettings} from '../../new-ui/utils/api'; import {TimeTravelFeature} from '@/constants'; +import type {TreePatch} from '@/tests-tree-builder/tree-patch'; export type InitGuiReportAction = Action; -const initGuiReport = (payload: InitGuiReportAction['payload']): InitGuiReportAction => +export const initGuiReport = (payload: InitGuiReportAction['payload']): InitGuiReportAction => ({type: actionNames.INIT_GUI_REPORT, payload}); +export type PatchTestsTreeAction = Action; +export const patchTestsTree = (payload: TreePatch): PatchTestsTreeAction => + ({type: actionNames.PATCH_TESTS_TREE, payload}); + interface InitGuiReportData { isNewUi?: boolean; } @@ -167,6 +172,7 @@ export const finStaticReport = (): FinStaticReportAction => ({type: actionNames. export type LifecycleAction = | InitGuiReportAction + | PatchTestsTreeAction | InitStaticReportAction | FinGuiReportAction | FinStaticReportAction; diff --git a/lib/static/modules/reducers/new-ui-grouped-tests/index.ts b/lib/static/modules/reducers/new-ui-grouped-tests/index.ts index 024c91fc8..0d587fd91 100644 --- a/lib/static/modules/reducers/new-ui-grouped-tests/index.ts +++ b/lib/static/modules/reducers/new-ui-grouped-tests/index.ts @@ -53,6 +53,33 @@ export default (state: State, action: SomeAction): State => { }); } + case actionNames.PATCH_TESTS_TREE: { + const startedAt = performance.now(); + const performanceId = action.payload.performance?.id ?? '?'; + const expressionIds = state.app.groupTestsData.currentExpressionIds; + + if (!expressionIds.length) { + console.info(`[watch-perf][client][#${performanceId}][grouping reducer] skipped: ${(performance.now() - startedAt).toFixed(1)}ms`); + return state; + } + + const expressions = expressionIds + .map(id => state.app.groupTestsData.availableExpressions.find(expr => expr.id === id) as GroupByExpression); + const groupsById = groupTests(expressions, state.tree.results.byId, state.tree.images.byId, state.config.errorPatterns); + console.info(`[watch-perf][client][#${performanceId}][grouping reducer] rebuild groups: ${(performance.now() - startedAt).toFixed(1)}ms`, { + groups: Object.keys(groupsById).length + }); + + return Object.assign({}, state, { + tree: Object.assign({}, state.tree, { + groups: { + byId: groupsById, + allRootIds: Object.keys(groupsById) + } + }) + }); + } + case actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION: { const newExpressionIds = action.payload.expressionIds; diff --git a/lib/static/modules/reducers/tree/index.js b/lib/static/modules/reducers/tree/index.js index 3326507ce..53399a654 100644 --- a/lib/static/modules/reducers/tree/index.js +++ b/lib/static/modules/reducers/tree/index.js @@ -1,9 +1,10 @@ -import {findLast, isEmpty, get} from 'lodash'; +import {findLast, isEmpty, get, isEqual} from 'lodash'; import {produce} from 'immer'; import actionNames from '../../action-names'; import { initSuitesState, changeAllSuitesState, changeSuiteState, updateSuitesStatus, getFailedRootSuiteIds, - updateAllSuitesStatus, calcSuitesShowness, calcSuitesOpenness, failSuites, updateParentsChecked + updateAllSuitesStatus, updateParentSuitesStatus, calcSuitesShowness, calcAffectedSuitesShowness, + calcSuitesOpenness, failSuites, updateParentsChecked } from './nodes/suites'; import { initBrowsersState, changeAllBrowsersState, changeBrowserState, getBrowserParentId, @@ -21,7 +22,7 @@ import {applyStateUpdate, ensureDiffProperty, getUpdatedProperty} from '../../ut import {changeNodeState, getStaticAccepterStateNameImages, resolveUpdatedStatuses, updateImagesStatus} from './helpers'; import * as staticImageAccepter from '../../static-image-accepter'; import {CHECKED, UNCHECKED} from '@/constants/checked-statuses'; -import {initSearch} from '@/static/modules/search'; +import {initSearch, patchSearch} from '@/static/modules/search'; export default ((state, action) => { const diff = {tree: {}}; @@ -63,6 +64,80 @@ export default ((state, action) => { }), tree}; } + case actionNames.PATCH_TESTS_TREE: { + const reducerStartedAt = performance.now(); + const performanceId = action.payload.performance?.id ?? '?'; + const logStage = (operation, startedAt) => { + console.info(`[watch-perf][client][#${performanceId}][reducer] ${operation}: ${(performance.now() - startedAt).toFixed(1)}ms`); + }; + const nextState = produce(state, (draft) => { + const {tree, view, app} = draft; + const patch = action.payload; + + let stageStartedAt = performance.now(); + applyTreePatch(tree, patch); + tree.lastPatch = patch; + logStage('apply normalized tree patch', stageStartedAt); + + stageStartedAt = performance.now(); + patchSearch(tree, patch, action.payload.performance?.id); + logStage('patch search index', stageStartedAt); + + stageStartedAt = performance.now(); + patch.results.addedIds.forEach((resultId) => { + changeResultState({tree, resultId, state: {matchedSelectedGroup: false}}); + }); + if (patch.images.addedIds.length) { + calcImagesOpenness({tree, expand: view.expand, imageIds: patch.images.addedIds}); + } + logStage('initialize result/image states', stageStartedAt); + + stageStartedAt = performance.now(); + const affectedBrowserIds = Object.keys(patch.browsers.byId); + affectedBrowserIds.forEach((browserId) => { + if (patch.browsers.addedIds.includes(browserId)) { + changeBrowserState(tree, browserId, {checkStatus: UNCHECKED}); + } + setBrowsersLastRetry(tree, browserId); + }); + + if (affectedBrowserIds.length) { + if (view.keyToGroupTestsBy) { + affectedBrowserIds.forEach(browserId => changeBrowserState(tree, browserId, {shouldBeShown: false})); + } else { + calcBrowsersShowness({tree, view, app, browserIds: affectedBrowserIds}); + } + calcBrowsersOpenness({tree, expand: view.expand, browserIds: affectedBrowserIds}); + } + logStage('update browser states', stageStartedAt); + + stageStartedAt = performance.now(); + patch.suites.addedIds.forEach((suiteId) => { + changeSuiteState(tree, suiteId, {checkStatus: UNCHECKED}); + }); + + const existingAffectedSuiteIds = patch.affectedSuiteIds.filter(suiteId => tree.suites.byId[suiteId]); + const deepestAffectedSuiteIds = existingAffectedSuiteIds + .filter(suiteId => !tree.suites.byId[suiteId].suiteIds?.some(childId => existingAffectedSuiteIds.includes(childId))); + updateParentSuitesStatus(tree, deepestAffectedSuiteIds, app.filteredBrowsers); + if (view.keyToGroupTestsBy) { + patch.suites.addedIds.forEach(suiteId => changeSuiteState(tree, suiteId, {shouldBeShown: false})); + } else { + calcAffectedSuitesShowness({tree, suiteIds: existingAffectedSuiteIds}); + } + const affectedSuiteIds = Object.keys(patch.suites.byId); + if (affectedSuiteIds.length) { + calcSuitesOpenness({tree, expand: view.expand, suiteIds: affectedSuiteIds}); + } + tree.suites.failedRootIds = getFailedRootSuiteIds(tree.suites); + logStage('update suite statuses/states', stageStartedAt); + }); + + logStage('Immer finalize and reducer total', reducerStartedAt); + + return nextState; + } + case actionNames.RUN_ALL_TESTS: { const {tree} = state; @@ -452,6 +527,37 @@ function initNodesStates(state) { initResultsState(tree); } +function applyTreePatch(tree, patch) { + patch.suites.removedIds.forEach((suiteId) => { + const suite = tree.suites.byId[suiteId]; + if (suite) { + delete tree.suites.byHash[suite.hash]; + } + }); + + ['suites', 'browsers', 'results', 'images'].forEach((collectionName) => { + const collection = tree[collectionName]; + const collectionPatch = patch[collectionName]; + const removedIds = new Set(collectionPatch.removedIds); + + collectionPatch.removedIds.forEach((id) => { + delete collection.byId[id]; + delete collection.stateById[id]; + }); + Object.assign(collection.byId, collectionPatch.byId); + if (collectionPatch.removedIds.length || collectionPatch.addedIds.length) { + collection.allIds = collection.allIds.filter(id => !removedIds.has(id)).concat(collectionPatch.addedIds); + } + }); + + Object.values(patch.suites.byId).forEach((suite) => { + tree.suites.byHash[suite.hash] = suite; + }); + if (!isEqual(tree.suites.allRootIds, patch.suites.allRootIds)) { + tree.suites.allRootIds = patch.suites.allRootIds; + } +} + function addNodesToTree(state, payload) { const {tree, view, app} = state; diff --git a/lib/static/modules/reducers/tree/nodes/suites.js b/lib/static/modules/reducers/tree/nodes/suites.js index b1f9c30f4..1cba38b8e 100644 --- a/lib/static/modules/reducers/tree/nodes/suites.js +++ b/lib/static/modules/reducers/tree/nodes/suites.js @@ -94,6 +94,19 @@ export function calcSuitesShowness({tree, suiteIds = [], diff = tree}) { calcParentSuitesState(youngestSuites, tree, changeParentSuiteCb); } +export function calcAffectedSuitesShowness({tree, suiteIds, diff = tree}) { + suiteIds + .map(suiteId => tree.suites.byId[suiteId]) + .filter(Boolean) + .sort((a, b) => b.suitePath.length - a.suitePath.length) + .forEach((suite) => { + changeSuiteState(tree, suite.id, { + shouldBeShown: shouldSuiteBeShown(suite, tree, diff), + checkStatus: shouldSuiteBeChecked(suite, tree, diff) + }, diff); + }); +} + export function calcSuitesOpenness({tree, expand, suiteIds = [], diff = tree}) { if (expand !== EXPAND_RETRIES) { if (_.isEmpty(suiteIds)) { diff --git a/lib/static/modules/search/index.ts b/lib/static/modules/search/index.ts index 61021d705..45892abf8 100644 --- a/lib/static/modules/search/index.ts +++ b/lib/static/modules/search/index.ts @@ -1,37 +1,75 @@ import {setMatchCaseFilter, setSearchLoading, updateNameFilter} from '@/static/modules/actions'; import {Tree} from '@/tests-tree-builder/base'; +import type {TreePatch} from '@/tests-tree-builder/tree-patch'; import {AttachmentType, TagsAttachment} from '@/types'; let worker: Worker; let searchResult: Set = new Set([]); let searchResultPosition: Map = new Map([]); -export const initSearch = (tree: Tree): void => { +const getResultTags = (result: Tree['results']['byId'][string]): string[] => { + const tagsAttachment = result.attachments?.find(attachment => attachment.type === AttachmentType.Tags) as TagsAttachment; + + return tagsAttachment ? tagsAttachment.list.map(tag => tag.title) : []; +}; + +export const initSearch = (tree: Tree, performanceId?: number): void => { const list = tree.results.allIds; const idTagMap: Record = {}; list.forEach((id: string): void => { const result = tree.results.byId[id]; - const tagsAttachment = result.attachments?.find(attachment => attachment.type === AttachmentType.Tags) as TagsAttachment; - - if (tagsAttachment) { - idTagMap[result.parentId] = tagsAttachment.list.map(tag => tag.title); - } else { - idTagMap[result.parentId] = []; - } + idTagMap[result.parentId] = getResultTags(result); }); if (typeof Worker !== 'undefined') { + worker?.terminate(); worker = new Worker( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore /* webpackChunkName: "search-worker" */ new URL('./worker.ts', import.meta.url) ); - worker.postMessage({type: 'init', data: idTagMap}); + worker.postMessage({type: 'init', data: idTagMap, performanceId}); } }; +export const patchSearch = (tree: Tree, patch: TreePatch, performanceId?: number): void => { + if (typeof Worker === 'undefined') { + return; + } + + if (!worker) { + initSearch(tree, performanceId); + return; + } + + const affectedBrowserIds = new Set([ + ...Object.keys(patch.browsers.byId), + ...Object.values(patch.results.byId).map(result => result.parentId) + ]); + const idTagMap: Record = {}; + + affectedBrowserIds.forEach(browserId => { + const browser = tree.browsers.byId[browserId]; + const lastResultId = browser?.resultIds.at(-1); + const result = lastResultId ? tree.results.byId[lastResultId] : undefined; + + if (result) { + idTagMap[browserId] = getResultTags(result); + } + }); + + worker.postMessage({ + type: 'patch', + data: { + removeIds: [...patch.browsers.removedIds, ...affectedBrowserIds], + idTagMap + }, + performanceId + }); +}; + export const checkSearchResultExits = (browserId: string): boolean => searchResult.has(browserId); export const getSearchPosition = (item: string): number => searchResultPosition.get(item) || -1; diff --git a/lib/static/modules/search/worker.ts b/lib/static/modules/search/worker.ts index 5826acab2..67f1ec804 100644 --- a/lib/static/modules/search/worker.ts +++ b/lib/static/modules/search/worker.ts @@ -76,6 +76,7 @@ const search = (testNameFilter: string, matchCase = false): string[] => { type InitMessage = { type: 'init'; data: Record; + performanceId?: number; } type SearchMessage = { @@ -86,10 +87,49 @@ type SearchMessage = { }; } -self.onmessage = (event: MessageEvent): void => { +type PatchMessage = { + type: 'patch'; + data: { + removeIds: string[]; + idTagMap: Record; + }; + performanceId?: number; +} + +self.onmessage = (event: MessageEvent): void => { switch (event.data.type) { case 'init': { + const startedAt = performance.now(); initSearch(event.data.data); + if (event.data.performanceId !== undefined) { + console.info(`[watch-perf][client][#${event.data.performanceId}][search worker] rebuild index: ${(performance.now() - startedAt).toFixed(1)}ms`, { + items: Object.keys(event.data.data).length + }); + } + self.postMessage(true); + break; + } + case 'patch': { + const startedAt = performance.now(); + const removeIds = new Set(event.data.data.removeIds); + const preparedItems = Object.entries(event.data.data.idTagMap).map(([title, tags]) => ({ + title, + tags: '@' + tags.join(' @') + })); + + fuse.remove(item => removeIds.has(item.title)); + fuseMatchCase.remove(item => removeIds.has(item.title)); + preparedItems.forEach(item => { + fuse.add(item); + fuseMatchCase.add(item); + }); + + if (event.data.performanceId !== undefined) { + console.info(`[watch-perf][client][#${event.data.performanceId}][search worker] patch index: ${(performance.now() - startedAt).toFixed(1)}ms`, { + removed: removeIds.size, + upserted: preparedItems.length + }); + } self.postMessage(true); break; } diff --git a/lib/static/new-ui/app/gui.tsx b/lib/static/new-ui/app/gui.tsx index e6a9c218a..e13464bdb 100644 --- a/lib/static/new-ui/app/gui.tsx +++ b/lib/static/new-ui/app/gui.tsx @@ -1,5 +1,6 @@ import React, {ReactNode, useEffect} from 'react'; import {createRoot} from 'react-dom/client'; +import {flushSync} from 'react-dom'; import {ClientEvents} from '@/gui/constants'; import {App} from './App'; @@ -15,10 +16,11 @@ import { import {setGuiServerConnectionStatus} from '@/static/modules/actions/gui-server-connection'; import actionNames from '@/static/modules/action-names'; import {EventSourceProvider, useEventSource} from '@/static/new-ui/providers/event-source'; -import {thunkRefreshGuiReport} from '@/static/modules/actions/lifecycle'; +import {patchTestsTree} from '@/static/modules/actions/lifecycle'; const rootEl = document.getElementById('app') as HTMLDivElement; const root = createRoot(rootEl); +const watchRefreshStartedAt = new Map(); function Gui(): ReactNode { const eventSource = useEventSource(); @@ -68,15 +70,56 @@ function Gui(): ReactNode { store.dispatch(setRepeatLeft(data.repeatLeft)); }); - eventSource.addEventListener(ClientEvents.TESTS_REFRESH_STARTED, () => { - store.dispatch(setRefreshLoading(true)); + eventSource.addEventListener(ClientEvents.TESTS_REFRESH_STARTED, (e) => { + const {performanceId} = JSON.parse(e.data) as {performanceId: number}; + watchRefreshStartedAt.set(performanceId, performance.now()); + console.info(`[watch-perf][client][#${performanceId}] refresh event started`); + flushSync(() => { + store.dispatch(setRefreshLoading(true)); + }); }); - eventSource.addEventListener(ClientEvents.TESTS_REFRESHED, () => { - store.dispatch(thunkRefreshGuiReport()); + eventSource.addEventListener(ClientEvents.TESTS_REFRESHED, (e) => { + const handlerStartedAt = performance.now(); + let performanceId: number | string = '?'; + try { + const parseStartedAt = performance.now(); + const data = JSON.parse(e.data); + performanceId = data?.performance?.id ?? '?'; + console.info(`[watch-perf][client][#${performanceId}] JSON.parse: ${(performance.now() - parseStartedAt).toFixed(1)}ms`, { + payloadCharacters: e.data.length, + serverToClient: data?.performance?.serverCompletedAt + ? `${Date.now() - data.performance.serverCompletedAt}ms` + : 'unknown', + serverRefreshStartToClient: data?.performance?.serverStartedAt + ? `${Date.now() - data.performance.serverStartedAt}ms` + : 'unknown' + }); + if (data) { + const dispatchStartedAt = performance.now(); + store.dispatch(patchTestsTree(data)); + console.info(`[watch-perf][client][#${performanceId}] Redux dispatch including selectors: ${(performance.now() - dispatchStartedAt).toFixed(1)}ms`); + } + } finally { + console.info(`[watch-perf][client][#${performanceId}] refreshed handler total: ${(performance.now() - handlerStartedAt).toFixed(1)}ms`); + + if (typeof performanceId === 'number') { + const clientStartedAt = watchRefreshStartedAt.get(performanceId); + if (clientStartedAt !== undefined) { + console.info(`[watch-perf][client][#${performanceId}] from refresh-start event to completed handler: ${(performance.now() - clientStartedAt).toFixed(1)}ms`); + } + watchRefreshStartedAt.delete(performanceId); + } + store.dispatch(setRefreshLoading(false)); + + requestAnimationFrame(() => requestAnimationFrame(() => { + console.info(`[watch-perf][client][#${performanceId}] event handler + React render + next paint: ${(performance.now() - handlerStartedAt).toFixed(1)}ms`); + })); + } }); eventSource.addEventListener(ClientEvents.TESTS_REFRESH_FAILED, () => { + watchRefreshStartedAt.clear(); store.dispatch(setRefreshLoading(false)); }); }; diff --git a/lib/static/new-ui/components/TreeActionsToolbar/index.module.css b/lib/static/new-ui/components/TreeActionsToolbar/index.module.css index d7a1c6aff..e51b4a77c 100644 --- a/lib/static/new-ui/components/TreeActionsToolbar/index.module.css +++ b/lib/static/new-ui/components/TreeActionsToolbar/index.module.css @@ -24,7 +24,7 @@ to { transform: rotate(360deg); } } -.is-refresh-tests-loading { +.refresh-icon-loading { animation: spin 0.8s linear infinite; } diff --git a/lib/static/new-ui/features/suites/components/SuitesPage/selectors.ts b/lib/static/new-ui/features/suites/components/SuitesPage/selectors.ts index 912d16105..ed3d55890 100644 --- a/lib/static/new-ui/features/suites/components/SuitesPage/selectors.ts +++ b/lib/static/new-ui/features/suites/components/SuitesPage/selectors.ts @@ -15,12 +15,86 @@ import {buildTreeBottomUp, collectTreeLeafIds, formatEntityToTreeNodeData, sortT import {TestStatus} from '@/constants'; import {TreeViewData} from '@/static/new-ui/components/TreeView'; import {getCurrentResult} from '@/static/new-ui/features/suites/selectors'; -import {State} from '@/static/new-ui/types/store'; +import {hasBrowsers, hasSuites, SortDirection, SortType, State, TreeViewMode} from '@/static/new-ui/types/store'; +import type {TreePatch} from '@/tests-tree-builder/tree-patch'; +import {EntityType, TreeNode} from './types'; + +let previousTreeViewData: TreeViewData | undefined; +let previousTreePatch: TreePatch | undefined; + +const getLastTreePatch = (state: State): TreePatch | undefined => ( + (state.tree as State['tree'] & {lastPatch?: TreePatch}).lastPatch +); + +const collectRootBrowserIds = (rootIds: string[], suites: ReturnType): string[] => { + const browserIds = new Set(); + const pendingSuiteIds = [...rootIds]; + + while (pendingSuiteIds.length) { + const suite = suites[pendingSuiteIds.pop() as string]; + if (!suite) { + continue; + } + + if (hasBrowsers(suite)) { + suite.browserIds.forEach(id => browserIds.add(id)); + } + if (hasSuites(suite)) { + pendingSuiteIds.push(...suite.suiteIds); + } + } + + return [...browserIds]; +}; + +const reuseUnaffectedNodes = (newNodes: TreeNode[], previousNodes: TreeNode[], treePatch: TreePatch): TreeNode[] => { + const previousByEntityId = new Map(); + const affectedSuiteIds = new Set(treePatch.affectedSuiteIds); + const affectedBrowserIds = new Set(Object.keys(treePatch.browsers.byId)); + const indexPreviousNodes = (nodes: TreeNode[]): void => nodes.forEach((node) => { + previousByEntityId.set(`${node.data.entityType}:${node.data.entityId}`, node); + indexPreviousNodes(node.children ?? []); + }); + + indexPreviousNodes(previousNodes); + + const reuseNode = (node: TreeNode): TreeNode => { + const previousNode = previousByEntityId.get(`${node.data.entityType}:${node.data.entityId}`); + const isAffected = node.data.entityType === EntityType.Suite + ? affectedSuiteIds.has(node.data.entityId) + : affectedBrowserIds.has(node.data.entityId); + + if (previousNode && !isAffected) { + return previousNode; + } + + if (!node.children) { + return node; + } + + return {...node, children: node.children.map(reuseNode)}; + }; + + return newNodes.map(reuseNode); +}; // Converts the existing store structure to the one that can be consumed by GravityUI export const getSuitesTreeViewData = createSelector( - [getGroups, getSuites, getAllRootGroupIds, getBrowsers, getBrowsersState, getResults, getImages, getTreeViewMode, getSortTestsData, getBrowsersList], - (groups, suites, rootGroupIds, browsers, browsersState, results, images, treeViewMode, sortTestsData, browsersList): TreeViewData => { + [getGroups, getSuites, getAllRootGroupIds, getBrowsers, getBrowsersState, getResults, getImages, getTreeViewMode, getSortTestsData, getBrowsersList, getLastTreePatch], + (groups, suites, rootGroupIds, browsers, browsersState, results, images, treeViewMode, sortTestsData, browsersList, treePatch): TreeViewData => { + const selectorStartedAt = performance.now(); + const shouldMeasure = Boolean(treePatch && treePatch !== previousTreePatch); + const performanceId = treePatch?.performance?.id ?? '?'; + const logStage = (operation: string, startedAt: number, details?: Record): void => { + if (!shouldMeasure) { + return; + } + + console.info( + `[watch-perf][client][#${performanceId}][selector] ${operation}: ${(performance.now() - startedAt).toFixed(1)}ms`, + details ?? '' + ); + }; const currentSortDirection = sortTestsData.currentDirection; const currentSortExpression = sortTestsData.availableExpressions .find(expr => expr.id === sortTestsData.currentExpressionIds[0]) @@ -29,7 +103,54 @@ export const getSuitesTreeViewData = createSelector( const entitiesContext = {results, images, suites, treeViewMode, browsersState, browsers, groups, currentSortDirection, currentSortExpression, browsersList}; const isGroupingEnabled = rootGroupIds.length > 0; + + if ( + previousTreeViewData && + treePatch && + treePatch !== previousTreePatch && + !isGroupingEnabled && + treeViewMode === TreeViewMode.Tree && + currentSortExpression.type === SortType.ByName + ) { + let stageStartedAt = performance.now(); + const affectedRootIds = new Set(treePatch.affectedRootIds); + const unaffectedTreeNodes = previousTreeViewData.tree.filter(node => !affectedRootIds.has(node.data.entityId)); + const affectedBrowserIds = collectRootBrowserIds(treePatch.affectedRootIds, suites); + const affectedBrowsers = affectedBrowserIds + .filter(browserId => browsersState[browserId]?.shouldBeShown) + .map(browserId => browsers[browserId]); + logStage('collect affected branch', stageStartedAt, {roots: affectedRootIds.size, browsers: affectedBrowsers.length}); + + stageStartedAt = performance.now(); + const affectedTreeRoot = buildTreeBottomUp(entitiesContext, affectedBrowsers); + logStage('build affected branch', stageStartedAt); + + stageStartedAt = performance.now(); + const affectedTreeNodes = reuseUnaffectedNodes( + sortTreeNodes(entitiesContext, affectedTreeRoot.children ?? []), + previousTreeViewData.tree, + treePatch + ); + const direction = currentSortDirection === SortDirection.Desc ? -1 : 1; + const treeNodes = [ + ...unaffectedTreeNodes, + ...affectedTreeNodes + ].sort((a, b) => a.data.title.join(' ').localeCompare(b.data.title.join(' ')) * direction); + logStage('sort branch and reuse unchanged nodes', stageStartedAt); + + stageStartedAt = performance.now(); + const {allTreeNodeIds, visibleTreeNodeIds} = collectTreeLeafIds(treeNodes); + logStage('collect derived tree ids', stageStartedAt, {all: allTreeNodeIds.length, visible: visibleTreeNodeIds.length}); + + previousTreePatch = treePatch; + previousTreeViewData = {tree: treeNodes, allTreeNodeIds, visibleTreeNodeIds}; + logStage('incremental selector total', selectorStartedAt); + + return previousTreeViewData; + } + if (isGroupingEnabled) { + const fullBuildStartedAt = performance.now(); const treeNodes = rootGroupIds .map(rootId => { const groupEntity = groups[rootId]; @@ -49,22 +170,33 @@ export const getSuitesTreeViewData = createSelector( const sortedTreeNodes = sortTreeNodes(entitiesContext, treeNodes); const {allTreeNodeIds, visibleTreeNodeIds} = collectTreeLeafIds(sortedTreeNodes); - return { + previousTreePatch = treePatch; + previousTreeViewData = { tree: sortedTreeNodes, allTreeNodeIds, visibleTreeNodeIds }; + logStage('full grouped tree rebuild', fullBuildStartedAt, {all: allTreeNodeIds.length, visible: visibleTreeNodeIds.length}); + logStage('selector total', selectorStartedAt); + + return previousTreeViewData; } + const fullBuildStartedAt = performance.now(); const suitesTreeRoot = buildTreeBottomUp(entitiesContext, Object.values(browsers).filter(browser => browsersState[browser.id].shouldBeShown)); suitesTreeRoot.children = sortTreeNodes(entitiesContext, suitesTreeRoot.children ?? []); const {allTreeNodeIds, visibleTreeNodeIds} = collectTreeLeafIds([suitesTreeRoot]); - return { + previousTreePatch = treePatch; + previousTreeViewData = { allTreeNodeIds, visibleTreeNodeIds, tree: suitesTreeRoot.children ?? [] }; + logStage('full tree rebuild', fullBuildStartedAt, {all: allTreeNodeIds.length, visible: visibleTreeNodeIds.length}); + logStage('selector total', selectorStartedAt); + + return previousTreeViewData; }); export interface SuitesStatusCounts { diff --git a/lib/tests-tree-builder/base.ts b/lib/tests-tree-builder/base.ts index 0826b4fe0..8014b69d4 100644 --- a/lib/tests-tree-builder/base.ts +++ b/lib/tests-tree-builder/base.ts @@ -17,7 +17,7 @@ export interface TreeTestResult extends BaseTreeTestResult { attempt: number; } -interface TreeBrowser { +export interface TreeBrowser { id: string; name: string; parentId: string; diff --git a/lib/tests-tree-builder/gui.ts b/lib/tests-tree-builder/gui.ts index b94f7f300..0159b8e28 100644 --- a/lib/tests-tree-builder/gui.ts +++ b/lib/tests-tree-builder/gui.ts @@ -1,8 +1,10 @@ import _ from 'lodash'; +import path from 'node:path'; import {BaseTestsTreeBuilder, Tree, TreeImage, TreeTestResult, TreeSuite} from './base'; import {TestStatus, UPDATED} from '../constants'; import {isUpdatedStatus} from '../common-utils'; import {ImageFile, ImageInfoWithState} from '../types'; +import type {ReporterTestResult} from '../adapters/test-result'; interface SuiteBranch { id: string; @@ -41,6 +43,32 @@ interface TestUndoRefUpdateData { } export class GuiTestsTreeBuilder extends BaseTestsTreeBuilder { + private _browserIdsByFile = new Map>(); + + addTestResult(formattedResult: ReporterTestResult): void { + super.addTestResult(formattedResult); + + const file = formattedResult.file; + if (typeof file !== 'string') { + return; + } + + const browserId = this._buildId(this._buildId(formattedResult.testPath), formattedResult.browserId); + const normalizedFile = path.resolve(file); + const browserIds = this._browserIdsByFile.get(normalizedFile) ?? new Set(); + browserIds.add(browserId); + this._browserIdsByFile.set(normalizedFile, browserIds); + } + + removeTestsByFiles(files: string[]): void { + const normalizedFiles = new Set(files.map(file => path.resolve(file))); + const browserIds = _.uniq([...normalizedFiles].flatMap(file => [...this._browserIdsByFile.get(file) ?? []])); + + browserIds.forEach(browserId => this._removeBrowser(browserId)); + normalizedFiles.forEach(file => this._browserIdsByFile.delete(file)); + this.sortTree(); + } + getImagesInfo(testId: string): TreeImage[] { return this._tree.results.byId[testId].imageIds.map((imageId) => { return this._tree.images.byId[imageId]; @@ -150,8 +178,8 @@ export class GuiTestsTreeBuilder extends BaseTestsTreeBuilder { }; } - reuseTestsTree(testsTree: Tree): void { - this._tree.browsers.allIds.forEach((browserId) => this._reuseBrowser(testsTree, browserId)); + reuseTestsTree(testsTree: Tree, {replaceCurrentResults = false}: {replaceCurrentResults?: boolean} = {}): void { + this._tree.browsers.allIds.forEach((browserId) => this._reuseBrowser(testsTree, browserId, replaceCurrentResults)); } updateImageInfo(imageId: string, imageInfo?: TreeImage | null): TreeImage { @@ -189,13 +217,53 @@ export class GuiTestsTreeBuilder extends BaseTestsTreeBuilder { }); } - private _reuseBrowser(testsTree: Tree, browserId: string): void { + private _removeBrowser(browserId: string): void { + const browser = this._tree.browsers.byId[browserId]; + if (!browser) { + return; + } + + browser.resultIds.filter(Boolean).forEach(resultId => this.removeTestResult(resultId)); + const suite = this._tree.suites.byId[browser.parentId]; + suite.browserIds = suite.browserIds?.filter(id => id !== browserId); + this._tree.browsers.allIds = this._tree.browsers.allIds.filter(id => id !== browserId); + delete this._tree.browsers.byId[browserId]; + + this._removeEmptySuiteOrUpdateStatus(suite); + } + + private _removeEmptySuiteOrUpdateStatus(suite: TreeSuite): void { + if (suite.browserIds?.length || suite.suiteIds?.length) { + this._setStatusForBranch(suite.suitePath); + return; + } + + const parent = suite.parentId ? this._tree.suites.byId[suite.parentId] : null; + if (parent) { + parent.suiteIds = parent.suiteIds?.filter(id => id !== suite.id); + } + this._tree.suites.allIds = this._tree.suites.allIds.filter(id => id !== suite.id); + this._tree.suites.allRootIds = this._tree.suites.allRootIds.filter(id => id !== suite.id); + delete this._tree.suites.byHash[suite.hash]; + delete this._tree.suites.byId[suite.id]; + + if (parent) { + this._removeEmptySuiteOrUpdateStatus(parent); + } + } + + private _reuseBrowser(testsTree: Tree, browserId: string, replaceCurrentResults: boolean): void { const reuseBrowser = testsTree.browsers.byId[browserId]; if (!reuseBrowser) { return; } + if (replaceCurrentResults) { + const currentBrowser = this._tree.browsers.byId[browserId]; + currentBrowser.resultIds.filter(Boolean).forEach((resultId) => this.removeTestResult(resultId)); + } + this._tree.browsers.byId[browserId] = reuseBrowser; reuseBrowser.resultIds.forEach((resultId) => this._reuseResults(testsTree, resultId)); diff --git a/lib/tests-tree-builder/tree-patch.ts b/lib/tests-tree-builder/tree-patch.ts new file mode 100644 index 000000000..a76643599 --- /dev/null +++ b/lib/tests-tree-builder/tree-patch.ts @@ -0,0 +1,103 @@ +import type {Tree, TreeBrowser, TreeImage, TreeSuite, TreeTestResult} from './base'; + +export interface TreeCollectionPatch { + addedIds: string[]; + removedIds: string[]; + byId: Record; +} + +export interface TreePatch { + performance?: { + id: number; + serverStartedAt: number; + serverCompletedAt: number; + }; + affectedRootIds: string[]; + affectedSuiteIds: string[]; + suites: TreeCollectionPatch & { + allRootIds: string[]; + }; + browsers: TreeCollectionPatch; + results: TreeCollectionPatch; + images: TreeCollectionPatch; +} + +interface CollectionSnapshot { + ids: Set; + serializedById: Record; +} + +export interface TreeSnapshot { + suites: CollectionSnapshot; + browsers: CollectionSnapshot; + results: CollectionSnapshot; + images: CollectionSnapshot; +} + +export interface TreePatchScope { + suites: Set; + browsers: Set; + results: Set; + images: Set; +} + +const snapshotCollection = (byId: Record, scope?: Set): CollectionSnapshot => { + const entries = scope + ? [...scope].flatMap(id => byId[id] ? [[id, byId[id]] as [string, T]] : []) + : Object.entries(byId); + + return { + ids: new Set(entries.map(([id]) => id)), + serializedById: Object.fromEntries(entries.map(([id, node]) => [id, JSON.stringify(node)])) + }; +}; + +export const snapshotTree = (tree: Tree, scope?: TreePatchScope): TreeSnapshot => ({ + suites: snapshotCollection(tree.suites.byId, scope?.suites), + browsers: snapshotCollection(tree.browsers.byId, scope?.browsers), + results: snapshotCollection(tree.results.byId, scope?.results), + images: snapshotCollection(tree.images.byId, scope?.images) +}); + +const createCollectionPatch = (snapshot: CollectionSnapshot, byId: Record, allIds: string[], scope?: Set): TreeCollectionPatch => { + const candidateIds = scope ? [...new Set([...snapshot.ids, ...scope])] : allIds; + const addedIds = candidateIds.filter(id => byId[id] && !snapshot.ids.has(id)); + const removedIds = [...snapshot.ids].filter(id => !byId[id]); + const changedById = Object.fromEntries(candidateIds.flatMap((id) => { + const node = byId[id]; + + return node && (!snapshot.ids.has(id) || snapshot.serializedById[id] !== JSON.stringify(node)) + ? [[id, node]] + : []; + })); + + return {addedIds, removedIds, byId: changedById}; +}; + +export const createTreePatch = (snapshot: TreeSnapshot, tree: Tree, scope?: TreePatchScope): TreePatch => { + const suites = createCollectionPatch(snapshot.suites, tree.suites.byId, tree.suites.allIds, scope?.suites); + const changedSuites = Object.values(suites.byId); + const removedSuites = suites.removedIds.map(id => JSON.parse(snapshot.suites.serializedById[id]) as TreeSuite); + const affectedSuiteIds = new Set(); + + [...changedSuites, ...removedSuites].forEach((suite) => { + let currentSuite: TreeSuite | undefined = suite; + while (currentSuite) { + affectedSuiteIds.add(currentSuite.id); + const parentId: string | null = currentSuite.parentId; + const serializedParent: string | undefined = parentId ? snapshot.suites.serializedById[parentId] : undefined; + currentSuite = parentId + ? tree.suites.byId[parentId] ?? (serializedParent ? JSON.parse(serializedParent) as TreeSuite : undefined) + : undefined; + } + }); + + return { + affectedRootIds: [...new Set([...changedSuites, ...removedSuites].map(suite => suite.suitePath[0]))], + affectedSuiteIds: [...affectedSuiteIds], + suites: {...suites, allRootIds: tree.suites.allRootIds}, + browsers: createCollectionPatch(snapshot.browsers, tree.browsers.byId, tree.browsers.allIds, scope?.browsers), + results: createCollectionPatch(snapshot.results, tree.results.byId, tree.results.allIds, scope?.results), + images: createCollectionPatch(snapshot.images, tree.images.byId, tree.images.allIds, scope?.images) + }; +}; diff --git a/package-lock.json b/package-lock.json index 9f69f0d5f..6164cb497 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8033,7 +8033,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -9198,7 +9197,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, "engines": { "node": ">=8" } @@ -9935,7 +9933,6 @@ "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, "funding": [ { "type": "individual", @@ -18033,7 +18030,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -23400,7 +23396,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -26503,7 +26498,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -39665,7 +39659,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "dev": true, "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -40503,8 +40496,7 @@ "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" }, "bindings": { "version": "1.5.0", @@ -41059,7 +41051,6 @@ "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, "requires": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -47235,7 +47226,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "requires": { "binary-extensions": "^2.0.0" } @@ -51229,8 +51219,7 @@ "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" }, "normalize-url": { "version": "8.0.2", @@ -53577,7 +53566,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "requires": { "picomatch": "^2.2.1" } diff --git a/test/unit/lib/adapters/test/testplane.ts b/test/unit/lib/adapters/test/testplane.ts index 7a467b24c..e4b14c5ad 100644 --- a/test/unit/lib/adapters/test/testplane.ts +++ b/test/unit/lib/adapters/test/testplane.ts @@ -90,6 +90,20 @@ describe('lib/adapters/test/testplane', () => { }); }); + describe('titlePath', () => { + it('should preserve suite and test titles containing spaces', () => { + const test = mkState({ + title: 'test title', + parent: { + title: 'nested suite', + parent: {title: 'root suite', parent: null} + } + }) as unknown as Test; + + assert.deepEqual(TestplaneTestAdapter.create(test).titlePath, ['root suite', 'nested suite', 'test title']); + }); + }); + describe('createTestResult', () => { it('should return testplane test result adapter', () => { const testResultAdapter = {} as unknown as TestplaneTestResultAdapter; diff --git a/test/unit/lib/gui/tool-runner/index.js b/test/unit/lib/gui/tool-runner/index.js index bfd6ea121..e2cfcf9db 100644 --- a/test/unit/lib/gui/tool-runner/index.js +++ b/test/unit/lib/gui/tool-runner/index.js @@ -430,6 +430,94 @@ describe('lib/gui/tool-runner/index', () => { }); }); + describe('refreshTestsIfChanged', () => { + it('should not read the full test collection after a structural change', async () => { + const changedFile = '/ref/cwd/changed.hermione.ts'; + const oldTest = mkTestAdapter_(stubTest_({ + file: changedFile, + browserId: 'yabro', + fullTitle: () => 'old test' + })); + const newTest = mkTestAdapter_(stubTest_({ + file: changedFile, + browserId: 'yabro', + fullTitle: () => 'new test' + })); + const tree = { + suites: {byId: {}, byHash: {}, allIds: [], allRootIds: []}, + browsers: {byId: {}, allIds: []}, + results: {byId: {}, allIds: []}, + images: {byId: {}, allIds: []} + }; + toolAdapter.readTests.onFirstCall().resolves({tests: [oldTest]}); + toolAdapter.readTests.onSecondCall().resolves({tests: [newTest]}); + sandbox.stub(fs, 'pathExists').withArgs(changedFile).resolves(true); + sandbox.stub(reportBuilder, 'testsTree').get(() => tree); + const gui = initGuiReporter({toolAdapter}); + const onChanged = sandbox.stub(); + const onUpdated = sandbox.stub(); + + await gui.initialize(); + await gui.refreshTestsIfChanged([changedFile], [], onChanged, onUpdated, 1); + + assert.callCount(toolAdapter.readTests, 2); + assert.calledOnceWith(onChanged, true); + assert.calledOnce(onUpdated); + assert.calledOnceWith(reportBuilder.restoreTestHistory, [{ + suitePath: ['new', 'test'], + browserId: 'yabro' + }]); + }); + + it('should treat a changed file with no tests as an empty partial collection', async () => { + const changedFile = '/ref/cwd/changed.hermione.ts'; + const oldTest = mkTestAdapter_(stubTest_({ + file: changedFile, + browserId: 'yabro', + fullTitle: () => 'old test' + })); + const tree = { + suites: {byId: {}, byHash: {}, allIds: [], allRootIds: []}, + browsers: {byId: {}, allIds: []}, + results: {byId: {}, allIds: []}, + images: {byId: {}, allIds: []} + }; + toolAdapter.readTests.onFirstCall().resolves({tests: [oldTest]}); + toolAdapter.readTests.onSecondCall().rejects(new Error('There are no tests found. Try to specify options')); + sandbox.stub(fs, 'pathExists').withArgs(changedFile).resolves(true); + sandbox.stub(reportBuilder, 'testsTree').get(() => tree); + const gui = initGuiReporter({toolAdapter}); + const onChanged = sandbox.stub(); + const onUpdated = sandbox.stub(); + + await gui.initialize(); + await gui.refreshTestsIfChanged([changedFile], [], onChanged, onUpdated, 1); + + assert.callCount(toolAdapter.readTests, 2); + assert.calledOnceWith(onChanged, true); + assert.calledOnce(onUpdated); + }); + + it('should lazily read only the selected test file before running changed code', async () => { + const changedFile = '/ref/cwd/changed.hermione.ts'; + const test = mkTestAdapter_(stubTest_({ + file: changedFile, + browserId: 'yabro', + fullTitle: () => 'same test' + })); + toolAdapter.readTests.resolves({tests: [test]}); + sandbox.stub(fs, 'pathExists').withArgs(changedFile).resolves(true); + const gui = initGuiReporter({toolAdapter}); + + await gui.initialize(); + await gui.refreshTestsIfChanged([changedFile], [], sandbox.stub(), sandbox.stub(), 1); + await gui.run([{testName: 'same test', browserName: 'yabro'}]); + + assert.callCount(toolAdapter.readTests, 3); + assert.deepEqual(toolAdapter.readTests.thirdCall.args[0], [changedFile]); + }); + }); + describe('findEqualDiffs', () => { let compareOpts; diff --git a/test/unit/lib/sqlite-client.js b/test/unit/lib/sqlite-client.js index 51be4ddf6..d388f9135 100644 --- a/test/unit/lib/sqlite-client.js +++ b/test/unit/lib/sqlite-client.js @@ -78,6 +78,26 @@ describe('lib/sqlite-client', () => { }); }); + it('should read history only for selected tests', async () => { + const client = await makeSqliteClient_(); + const db = client.getRawConnection(); + const placeholders = Array(16).fill('?').join(', '); + const mkRow = (suitePath, browserId, timestamp) => [ + JSON.stringify(suitePath), suitePath.at(-1), browserId, '', '{}', '[]', null, null, + null, '[]', 0, 0, 'success', timestamp, 0, '[]' + ]; + + db.run(`INSERT INTO suites VALUES (${placeholders})`, mkRow(['suite', 'first'], 'chrome', 2)); + db.run(`INSERT INTO suites VALUES (${placeholders})`, mkRow(['suite', 'second'], 'chrome', 1)); + + const rows = client.getSuitesByTests([{suitePath: ['suite', 'first'], browserId: 'chrome'}]); + + assert.lengthOf(rows, 1); + assert.equal(rows[0][0], JSON.stringify(['suite', 'first'])); + assert.equal(rows[0][13], 2); + client.close(); + }); + describe('query', () => { let getAsObjectStub, prepareStub, freeStub, sqliteClient; diff --git a/test/unit/lib/static/modules/reducers/tree/index.js b/test/unit/lib/static/modules/reducers/tree/index.js index 93a5f8590..30178b4df 100644 --- a/test/unit/lib/static/modules/reducers/tree/index.js +++ b/test/unit/lib/static/modules/reducers/tree/index.js @@ -766,6 +766,47 @@ describe('lib/static/modules/reducers/tree', () => { }); }); + describe(`${actionNames.PATCH_TESTS_TREE} action`, () => { + it('should preserve unchanged nodes and add only nodes from the patch', () => { + const suitesById = mkSuite({id: 's1', hash: 'h1', root: true, browserIds: ['b1']}); + const browsersById = mkBrowser({id: 'b1', name: 'yabro', parentId: 's1', resultIds: ['r1']}); + const resultsById = mkResult({id: 'r1', parentId: 'b1'}); + const tree = mkStateTree({suitesById, suitesAllRootIds: ['s1'], browsersById, resultsById}); + tree.suites.byHash = {h1: suitesById.s1}; + const state = reducer({view: mkStateView(), app: mkStatePageFilters({}), config: {}}, { + type: actionNames.INIT_GUI_REPORT, + payload: {tree} + }); + const unchangedSuite = state.tree.suites.byId.s1; + const unchangedBrowser = state.tree.browsers.byId.b1; + const unchangedResult = state.tree.results.byId.r1; + const unchangedSuiteState = state.tree.suites.stateById.s1; + const addedSuite = mkSuite({id: 's2', hash: 'h2', root: true, browserIds: ['b2']}).s2; + const addedBrowser = mkBrowser({id: 'b2', name: 'yabro', parentId: 's2', resultIds: ['r2']}).b2; + const addedResult = mkResult({id: 'r2', parentId: 'b2'}).r2; + + const newState = reducer(state, { + type: actionNames.PATCH_TESTS_TREE, + payload: { + affectedRootIds: ['s2'], + affectedSuiteIds: ['s2'], + suites: {addedIds: ['s2'], removedIds: [], byId: {s2: addedSuite}, allRootIds: ['s1', 's2']}, + browsers: {addedIds: ['b2'], removedIds: [], byId: {b2: addedBrowser}}, + results: {addedIds: ['r2'], removedIds: [], byId: {r2: addedResult}}, + images: {addedIds: [], removedIds: [], byId: {}} + } + }); + + assert.strictEqual(newState.tree.suites.byId.s1, unchangedSuite); + assert.strictEqual(newState.tree.browsers.byId.b1, unchangedBrowser); + assert.strictEqual(newState.tree.results.byId.r1, unchangedResult); + assert.strictEqual(newState.tree.suites.stateById.s1, unchangedSuiteState); + assert.equal(newState.tree.suites.byId.s2, addedSuite); + assert.equal(newState.tree.browsers.byId.b2, addedBrowser); + assert.equal(newState.tree.results.byId.r2, addedResult); + }); + }); + [actionNames.TEST_BEGIN, actionNames.TEST_RESULT, actionNames.COMMIT_ACCEPTED_IMAGES_TO_TREE].forEach((actionName) => { describe(`${actionName} action`, () => { it('should change "retryIndex" in browser state', () => { diff --git a/test/unit/lib/static/new-ui/features/suites/components/SuitesPage/selectors.js b/test/unit/lib/static/new-ui/features/suites/components/SuitesPage/selectors.js new file mode 100644 index 000000000..84a0a1393 --- /dev/null +++ b/test/unit/lib/static/new-ui/features/suites/components/SuitesPage/selectors.js @@ -0,0 +1,66 @@ +'use strict'; + +const {getSuitesTreeViewData} = require('lib/static/new-ui/features/suites/components/SuitesPage/selectors'); +const {SortDirection, SortType, TreeViewMode} = require('lib/static/new-ui/types/store'); +const {SUCCESS} = require('lib/constants/test-statuses'); + +describe('SuitesPage selectors', () => { + it('should preserve tree nodes outside of patched roots', () => { + const makeSuite = id => ({id, hash: `hash-${id}`, name: id, parentId: null, status: SUCCESS, browserIds: [`browser-${id}`], suitePath: [id]}); + const makeBrowser = id => ({id: `browser-${id}`, name: 'chrome', parentId: id, resultIds: [`result-${id}`]}); + const makeResult = id => ({id: `result-${id}`, parentId: `browser-${id}`, attempt: 0, imageIds: [], status: SUCCESS, timestamp: 0, metaInfo: {}, suitePath: [id]}); + const suites = {first: makeSuite('first'), second: makeSuite('second')}; + const browsers = {['browser-first']: makeBrowser('first'), ['browser-second']: makeBrowser('second')}; + const results = {['result-first']: makeResult('first'), ['result-second']: makeResult('second')}; + const browserState = {shouldBeShown: true, retryIndex: 0}; + const state = { + tree: { + groups: {byId: {}, allRootIds: []}, + suites: {byId: suites}, + browsers: {byId: browsers, stateById: {['browser-first']: browserState, ['browser-second']: browserState}}, + results: {byId: results}, + images: {byId: {}} + }, + ui: {suitesPage: {treeViewMode: TreeViewMode.Tree}}, + app: { + sortTestsData: { + currentDirection: SortDirection.Asc, + currentExpressionIds: ['by-name'], + availableExpressions: [{id: 'by-name', label: 'Name', type: SortType.ByName}] + } + }, + browsers: [{id: 'chrome', versions: []}] + }; + const initialTreeData = getSuitesTreeViewData(state); + const firstRootNode = initialTreeData.tree.find(node => node.data.entityId === 'first'); + const updatedSecond = {...suites.second, browserIds: ['browser-second', 'browser-third']}; + const thirdBrowser = makeBrowser('third'); + thirdBrowser.parentId = 'second'; + thirdBrowser.name = 'firefox'; + const nextState = { + ...state, + tree: { + ...state.tree, + lastPatch: { + affectedRootIds: ['second'], + affectedSuiteIds: ['second'], + suites: {addedIds: [], removedIds: [], byId: {second: updatedSecond}, allRootIds: ['first', 'second']}, + browsers: {addedIds: ['browser-third'], removedIds: [], byId: {['browser-third']: thirdBrowser}}, + results: {addedIds: ['result-third'], removedIds: [], byId: {['result-third']: makeResult('third')}}, + images: {addedIds: [], removedIds: [], byId: {}} + }, + suites: {...state.tree.suites, byId: {...suites, second: updatedSecond}}, + browsers: { + byId: {...browsers, ['browser-third']: thirdBrowser}, + stateById: {...state.tree.browsers.stateById, ['browser-third']: browserState} + }, + results: {byId: {...results, ['result-third']: makeResult('third')}} + } + }; + + const updatedTreeData = getSuitesTreeViewData(nextState); + + assert.strictEqual(updatedTreeData.tree.find(node => node.data.entityId === 'first'), firstRootNode); + assert.lengthOf(updatedTreeData.tree.find(node => node.data.entityId === 'second').children, 2); + }); +}); diff --git a/test/unit/lib/tests-tree-builder/gui.js b/test/unit/lib/tests-tree-builder/gui.js index d0eb5ec1b..cfb744dd4 100644 --- a/test/unit/lib/tests-tree-builder/gui.js +++ b/test/unit/lib/tests-tree-builder/gui.js @@ -111,6 +111,23 @@ describe('GuiResultsTreeBuilder', () => { assert.deepEqual(builder.tree.results.byId['s1 b1 1'], srcBuilder.tree.results.byId['s1 b1 1']); }); + it('should replace temporary results when explicitly requested', () => { + const srcBuilder = mkGuiTreeBuilder(); + srcBuilder.addTestResult( + mkFormattedResult_({status: SUCCESS, testPath: ['s1'], browserId: 'b1', attempt: 0}) + ); + + builder.addTestResult( + mkFormattedResult_({status: IDLE, testPath: ['s1'], browserId: 'b1', attempt: 1}) + ); + + builder.reuseTestsTree(srcBuilder.tree, {replaceCurrentResults: true}); + + assert.deepEqual(builder.tree.browsers.byId['s1 b1'].resultIds, ['s1 b1 0']); + assert.deepEqual(builder.tree.results.allIds, ['s1 b1 0']); + assert.isUndefined(builder.tree.results.byId['s1 b1 1']); + }); + it('should register reused result ids', () => { const srcBuilder = mkGuiTreeBuilder(); srcBuilder.addTestResult( @@ -224,6 +241,48 @@ describe('GuiResultsTreeBuilder', () => { }); }); + describe('"removeTestsByFiles" method', () => { + it('should remove only branches from passed files and prune empty suites', () => { + builder.addTestResult(mkFormattedResult_({ + status: IDLE, + file: '/project/changed.ts', + testPath: ['root', 'changed'], + browserId: 'chrome' + })); + builder.addTestResult(mkFormattedResult_({ + status: IDLE, + file: '/project/unchanged.ts', + testPath: ['root', 'unchanged'], + browserId: 'chrome' + })); + + builder.removeTestsByFiles(['/project/changed.ts']); + + assert.isUndefined(builder.tree.suites.byId['root changed']); + assert.isUndefined(builder.tree.browsers.byId['root changed chrome']); + assert.isUndefined(builder.tree.results.byId['root changed chrome 0']); + assert.exists(builder.tree.suites.byId['root unchanged']); + assert.exists(builder.tree.browsers.byId['root unchanged chrome']); + assert.exists(builder.tree.results.byId['root unchanged chrome 0']); + }); + + it('should remove an empty root suite', () => { + builder.addTestResult(mkFormattedResult_({ + status: IDLE, + file: '/project/only.ts', + testPath: ['only root'], + browserId: 'chrome' + })); + + builder.removeTestsByFiles(['/project/only.ts']); + + assert.deepEqual(builder.tree.suites.allRootIds, []); + assert.deepEqual(builder.tree.suites.allIds, []); + assert.deepEqual(builder.tree.browsers.allIds, []); + assert.deepEqual(builder.tree.results.allIds, []); + }); + }); + describe('"getResultDataToUnacceptImage" method', () => { it('should return "shouldRemoveResult: true" if it is the only updated image in result', () => { const formattedRes1 = mkFormattedResult_({testPath: ['s'], browserId: 'b', attempt: 0, imagesInfo: [ diff --git a/test/unit/lib/tests-tree-builder/tree-patch.js b/test/unit/lib/tests-tree-builder/tree-patch.js new file mode 100644 index 000000000..073d2138a --- /dev/null +++ b/test/unit/lib/tests-tree-builder/tree-patch.js @@ -0,0 +1,61 @@ +'use strict'; + +const {createTreePatch, snapshotTree} = require('lib/tests-tree-builder/tree-patch'); + +describe('tree patch', () => { + it('should include only added, removed and changed nodes', () => { + const tree = { + suites: { + byId: { + unchanged: {id: 'unchanged', hash: 'h1', name: 'unchanged', parentId: null, root: true, suitePath: ['unchanged']}, + removed: {id: 'removed', hash: 'h2', name: 'removed', parentId: null, root: true, suitePath: ['removed']} + }, + byHash: {}, + allIds: ['unchanged', 'removed'], + allRootIds: ['unchanged', 'removed'] + }, + browsers: {byId: {}, allIds: []}, + results: {byId: {}, allIds: []}, + images: {byId: {}, allIds: []} + }; + const snapshot = snapshotTree(tree); + + delete tree.suites.byId.removed; + tree.suites.byId.added = {id: 'added', hash: 'h3', name: 'added', parentId: null, root: true, suitePath: ['added']}; + tree.suites.allIds = ['unchanged', 'added']; + tree.suites.allRootIds = ['added', 'unchanged']; + + const patch = createTreePatch(snapshot, tree); + + assert.deepEqual(patch.suites.addedIds, ['added']); + assert.deepEqual(patch.suites.removedIds, ['removed']); + assert.deepEqual(patch.affectedRootIds, ['added', 'removed']); + assert.deepEqual(patch.affectedSuiteIds, ['added', 'removed']); + assert.deepEqual(Object.keys(patch.suites.byId), ['added']); + assert.deepEqual(patch.suites.allRootIds, ['added', 'unchanged']); + assert.deepEqual(patch.browsers.byId, {}); + }); + + it('should not inspect or include nodes outside the passed scope', () => { + const tree = { + suites: { + byId: { + affected: {id: 'affected', hash: 'h1', name: 'affected', parentId: null, root: true, suitePath: ['affected']}, + untouched: {id: 'untouched', hash: 'h2', name: 'untouched', parentId: null, root: true, suitePath: ['untouched']} + }, + byHash: {}, allIds: ['affected', 'untouched'], allRootIds: ['affected', 'untouched'] + }, + browsers: {byId: {}, allIds: []}, + results: {byId: {}, allIds: []}, + images: {byId: {}, allIds: []} + }; + const scope = {suites: new Set(['affected']), browsers: new Set(), results: new Set(), images: new Set()}; + const snapshot = snapshotTree(tree, scope); + + tree.suites.byId.affected.name = 'changed'; + tree.suites.byId.untouched.name = 'also changed'; + const patch = createTreePatch(snapshot, tree, scope); + + assert.deepEqual(Object.keys(patch.suites.byId), ['affected']); + }); +}); From 23182367d5f547443ab3f64465340585af345b10 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Fri, 11 Sep 2026 04:11:43 +0700 Subject: [PATCH 3/4] fix: pr fixes --- lib/adapters/config/testplane.ts | 4 - lib/adapters/test-collection/index.ts | 1 + lib/adapters/test-collection/testplane.ts | 15 +- lib/adapters/tool/index.ts | 7 + lib/adapters/tool/testplane/index.ts | 98 ++++++- lib/gui/app.ts | 5 +- lib/gui/server.ts | 129 +-------- lib/gui/tests-watcher/index.ts | 175 ++++++++++++ lib/gui/tool-runner/index.ts | 252 +++++++++++++---- lib/report-builder/gui.ts | 48 +++- lib/sqlite-client.ts | 26 +- lib/static/modules/reducers/tree/index.js | 6 +- lib/static/modules/search/index.ts | 98 +++++-- lib/static/modules/search/types.ts | 30 +++ lib/static/modules/search/worker.ts | 36 +-- lib/static/new-ui/app/gui.tsx | 37 ++- lib/test-attempt-manager.ts | 27 ++ lib/tests-tree-builder/gui.ts | 255 +++++++++++++++--- .../lib/adapters/test-collection/testplane.ts | 9 + .../unit/lib/adapters/tool/testplane/index.ts | 78 ++++++ test/unit/lib/gui/tests-watcher/index.js | 74 +++++ test/unit/lib/gui/tool-runner/index.js | 200 +++++++++++++- test/unit/lib/sqlite-client.js | 26 +- test/unit/lib/static/modules/search/index.js | 101 +++++++ test/unit/lib/test-attempt-manager.js | 22 ++ test/unit/lib/tests-tree-builder/gui.js | 99 +++++++ 26 files changed, 1560 insertions(+), 298 deletions(-) create mode 100644 lib/gui/tests-watcher/index.ts create mode 100644 lib/static/modules/search/types.ts create mode 100644 test/unit/lib/gui/tests-watcher/index.js create mode 100644 test/unit/lib/static/modules/search/index.js create mode 100644 test/unit/lib/test-attempt-manager.js diff --git a/lib/adapters/config/testplane.ts b/lib/adapters/config/testplane.ts index b1985f28f..0e17620e2 100644 --- a/lib/adapters/config/testplane.ts +++ b/lib/adapters/config/testplane.ts @@ -29,10 +29,6 @@ export class TestplaneConfigAdapter implements ConfigAdapter { return this._config.forBrowser(browserId); } - getTestFilePatterns(): string[] { - return Object.values(this._config.sets).flatMap(({files}) => files); - } - getScreenshotPath(test: TestplaneTestAdapter, stateName: string): string { const {browserId} = test; diff --git a/lib/adapters/test-collection/index.ts b/lib/adapters/test-collection/index.ts index 0213c04c4..d12094c80 100644 --- a/lib/adapters/test-collection/index.ts +++ b/lib/adapters/test-collection/index.ts @@ -2,4 +2,5 @@ import type {TestAdapter} from '../test'; export interface TestCollectionAdapter { readonly tests: TestAdapter[]; + readonly hasFocusedTests?: boolean; } diff --git a/lib/adapters/test-collection/testplane.ts b/lib/adapters/test-collection/testplane.ts index 492723a80..1c7a6a126 100644 --- a/lib/adapters/test-collection/testplane.ts +++ b/lib/adapters/test-collection/testplane.ts @@ -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( - 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)); } @@ -27,4 +30,8 @@ export class TestplaneTestCollectionAdapter implements TestCollectionAdapter { get tests(): TestplaneTestAdapter[] { return this._testAdapters; } + + get hasFocusedTests(): boolean { + return this._hasFocusedTests; + } } diff --git a/lib/adapters/tool/index.ts b/lib/adapters/tool/index.ts index 8cda98e65..ea0056337 100644 --- a/lib/adapters/tool/index.ts +++ b/lib/adapters/tool/index.ts @@ -21,6 +21,11 @@ export interface UpdateReferenceOpts { state: string; } +export interface TestsWatchPlan { + paths: string[]; + roots: string[]; +} + export interface ToolAdapter { readonly toolName: ToolName; readonly config: ConfigAdapter; @@ -28,8 +33,10 @@ export interface ToolAdapter { readonly htmlReporter: HtmlReporter; readonly guiApi?: GuiApi; readonly browserFeatures: Record; + readonly hasFocusedTestsInLastRead?: boolean; initGuiApi(): void; + getTestsWatchPlan?(paths: string[], cliTool: CommanderStatic): TestsWatchPlan; readTests(paths: string[], cliTool: CommanderStatic): Promise; run(testCollection: TestCollectionAdapter, tests: TestSpec[], cliTool: CommanderStatic): Promise; runWithoutRetries(testCollection: TestCollectionAdapter, tests: TestSpec[], cliTool: CommanderStatic): Promise; diff --git a/lib/adapters/tool/testplane/index.ts b/lib/adapters/tool/testplane/index.ts index f94f877f5..ff24d0a39 100644 --- a/lib/adapters/tool/testplane/index.ts +++ b/lib/adapters/tool/testplane/index.ts @@ -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'; @@ -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'; @@ -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; @@ -54,6 +87,7 @@ export class TestplaneToolAdapter implements ToolAdapter { private _guiApi?: GuiApi; private _browserConfigs: ReturnType[]; private _retryCache: Record; + private _hasFocusedTestsInLastRead: boolean; static create( this: new (options: Options) => TestplaneToolAdapter, @@ -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; @@ -123,6 +158,10 @@ export class TestplaneToolAdapter implements ToolAdapter { return result; } + get hasFocusedTestsInLastRead(): boolean { + return this._hasFocusedTestsInLastRead; + } + initGuiApi(): void { this._guiApi = GuiApi.create(); @@ -130,14 +169,67 @@ export class TestplaneToolAdapter implements ToolAdapter { 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 { 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(); + const setFocusedTests = (): void => { + this._hasFocusedTestsInLastRead = true; + }; + const markFocusedTests = (): void => { + const mochaGlobals = globalThis as typeof globalThis & Record; + + 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 { diff --git a/lib/gui/app.ts b/lib/gui/app.ts index 880537b07..0917d34f3 100644 --- a/lib/gui/app.ts +++ b/lib/gui/app.ts @@ -1,11 +1,10 @@ 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'; import type {TestSpec} from '../adapters/tool/types'; -import type {TreePatch} from '../tests-tree-builder/tree-patch'; export class App { private _toolRunner: ToolRunner; @@ -63,7 +62,7 @@ export class App { changedFiles: string[], removedDirectories: string[], onChanged: (changed: boolean) => void, - onUpdated: (patch: TreePatch) => void, + onUpdated: (update: TestsTreeUpdate) => void, performanceId: number ): Promise { return this._toolRunner.refreshTestsIfChanged(changedFiles, removedDirectories, onChanged, onUpdated, performanceId); diff --git a/lib/gui/server.ts b/lib/gui/server.ts index 2c5782c38..3766ba1f8 100644 --- a/lib/gui/server.ts +++ b/lib/gui/server.ts @@ -1,5 +1,4 @@ import path from 'path'; -import {performance} from 'node:perf_hooks'; import express from 'express'; import {onExit} from 'signal-exit'; import bodyParser from 'body-parser'; @@ -20,9 +19,8 @@ import type {TestplaneToolAdapter} from '../adapters/tool/testplane'; import type {ToolRunnerTree} from './tool-runner'; import type {TestplaneConfigAdapter} from '../adapters/config/testplane'; import type {UpdateTimeTravelSettingsRequest, UpdateTimeTravelSettingsResponse} from '../types'; -import type {TreePatch} from '../tests-tree-builder/tree-patch'; import chalk from 'chalk'; -import chokidar from 'chokidar'; +import {TestsWatcher} from './tests-watcher'; interface CustomGuiError { response: { @@ -35,14 +33,6 @@ type TimeTravelConfig = Config['timeTravel']; const originalBrowserConfigs = new Map(); -const getWatchRoot = (pattern: string): string => { - const normalizedPattern = pattern.replaceAll('\\', '/'); - const globStart = normalizedPattern.search(/[!*?()[\]{}]/); - const staticPart = globStart === -1 ? normalizedPattern : normalizedPattern.slice(0, globStart); - - return staticPart.endsWith('/') ? staticPart.slice(0, -1) : path.dirname(staticPart); -}; - export type GetInitResponse = (ToolRunnerTree & {customGuiError?: CustomGuiError} & { browserFeatures: Record, features: Feature[]}) | null; export const start = async (args: ServerArgs): Promise => { @@ -288,12 +278,10 @@ export const start = async (args: ServerArgs): Promise => { } }); - let testsWatcher: chokidar.FSWatcher | undefined; - let testDirectoriesWatcher: chokidar.FSWatcher | undefined; + let testsWatcher: TestsWatcher | undefined; onExit(() => { testsWatcher?.close(); - testDirectoriesWatcher?.close(); app.finalize(); logger.log('server shutting down'); }); @@ -321,115 +309,12 @@ export const start = async (args: ServerArgs): Promise => { await app.initialize(); if (args.cli.options.watch && toolAdapter.toolName === ToolName.Testplane) { - const config = toolAdapter.config as TestplaneConfigAdapter; - const watchPaths = [...new Set([...config.getTestFilePatterns(), ...args.paths])]; - const watchRoots = [...new Set(watchPaths.map(getWatchRoot).filter(Boolean))]; - let refreshInProgress = false; - const queuedFiles = new Set(); - const queuedRemovedDirectories = new Set(); - const debounceFiles = new Set(); - const debounceRemovedDirectories = new Set(); - let refreshTimer: NodeJS.Timeout | undefined; - let refreshSequence = 0; - let firstDebouncedEventAt: number | undefined; - - const refresh = async (changedFiles: string[], removedDirectories: string[]): Promise => { - changedFiles.forEach(file => queuedFiles.add(path.resolve(process.cwd(), file))); - removedDirectories.forEach(directory => queuedRemovedDirectories.add(path.resolve(process.cwd(), directory))); - - if (refreshInProgress) { - return; - } + const plan = toolAdapter.getTestsWatchPlan?.(args.paths, args.cli.tool); - refreshInProgress = true; - try { - while (queuedFiles.size) { - const refreshId = ++refreshSequence; - const serverStartedAt = Date.now(); - const refreshStartedAt = performance.now(); - const files = [...queuedFiles]; - const removedDirs = [...queuedRemovedDirectories]; - queuedFiles.clear(); - queuedRemovedDirectories.clear(); - let changed = false; - let treePatch: TreePatch | undefined; - logger.log(`[watch-perf][server][#${refreshId}] refresh started ${JSON.stringify({files: files.length, removedDirectories: removedDirs.length})}`); - await app.refreshTestsIfChanged(files, removedDirs, (hasChanges) => { - changed = hasChanges; - if (hasChanges) { - app.sendClientEvent(ClientEvents.TESTS_REFRESH_STARTED, {performanceId: refreshId}); - } - }, (patch) => { - treePatch = patch; - }, refreshId); - - if (changed && treePatch) { - treePatch.performance = { - id: refreshId, - serverStartedAt, - serverCompletedAt: Date.now() - }; - const sendStartedAt = performance.now(); - app.sendClientEvent(ClientEvents.TESTS_REFRESHED, treePatch); - logger.log(`[watch-perf][server][#${refreshId}] serialize/write SSE: ${(performance.now() - sendStartedAt).toFixed(1)}ms`); - } - logger.log(`[watch-perf][server][#${refreshId}] refresh loop total: ${(performance.now() - refreshStartedAt).toFixed(1)}ms ${JSON.stringify({changed})}`); - } - } catch (error) { - app.sendClientEvent(ClientEvents.TESTS_REFRESH_FAILED, undefined); - logger.error(`Error while refreshing tests after file change: ${(error as Error).message}`); - } finally { - refreshInProgress = false; - } - }; - - testsWatcher = chokidar.watch(watchPaths, { - cwd: process.cwd(), - ignoreInitial: true, - ignored: [ - /(^|[/\\])\../, - /(^|[/\\])node_modules([/\\]|$)/, - path.resolve(process.cwd(), reporterConfig.path) - ], - awaitWriteFinish: {stabilityThreshold: 200, pollInterval: 100} - }); - const queueFileSystemEvent = (event: string, changedFile: string): void => { - logger.log(`[watch-perf][server] chokidar event ${JSON.stringify({event, path: changedFile})}`); - if (debounceFiles.size === 0) { - firstDebouncedEventAt = performance.now(); - } - debounceFiles.add(changedFile); - if (event === 'unlinkDir') { - debounceRemovedDirectories.add(changedFile); - } - if (refreshTimer) { - clearTimeout(refreshTimer); - } - refreshTimer = setTimeout(() => { - refreshTimer = undefined; - logger.log(`[watch-perf][server] chokidar debounce: ${firstDebouncedEventAt === undefined ? 0 : (performance.now() - firstDebouncedEventAt).toFixed(1)}ms ${JSON.stringify({events: debounceFiles.size})}`); - firstDebouncedEventAt = undefined; - const changedFiles = [...debounceFiles]; - const removedDirectories = [...debounceRemovedDirectories]; - debounceFiles.clear(); - debounceRemovedDirectories.clear(); - void refresh(changedFiles, removedDirectories); - }, 100); - }; - - testsWatcher.on('all', queueFileSystemEvent); - - // A file glob does not necessarily subscribe Chokidar to directory - // lifecycle events. Watch the non-glob roots separately so deleting a - // directory is always observable. - testDirectoriesWatcher = chokidar.watch(watchRoots, { - cwd: process.cwd(), - ignoreInitial: true, - ignored: [/(^|[/\\])\../, /(^|[/\\])node_modules([/\\]|$)/] - }); - testDirectoriesWatcher.on('unlinkDir', changedDirectory => { - queueFileSystemEvent('unlinkDir', changedDirectory); - }); + if (plan) { + testsWatcher = TestsWatcher.create({app, plan, reportPath: reporterConfig.path}); + testsWatcher.start(); + } } const {port: requestedPort, hostname} = args.cli.options; diff --git a/lib/gui/tests-watcher/index.ts b/lib/gui/tests-watcher/index.ts new file mode 100644 index 000000000..1ff89fdd8 --- /dev/null +++ b/lib/gui/tests-watcher/index.ts @@ -0,0 +1,175 @@ +import path from 'node:path'; +import {performance} from 'node:perf_hooks'; + +import chokidar from 'chokidar'; + +import {logger} from '../../common-utils'; +import {ClientEvents} from '../constants'; +import type {TestsWatchPlan} from '../../adapters/tool'; +import type {TestsTreeUpdate} from '../tool-runner'; + +interface RefreshTarget { + refreshTestsIfChanged( + changedFiles: string[], + removedDirectories: string[], + onChanged: (changed: boolean) => void, + onUpdated: (update: TestsTreeUpdate) => void, + performanceId: number + ): Promise; + sendClientEvent(event: string, data: unknown): void; +} + +interface TestsWatcherOptions { + app: RefreshTarget; + plan: TestsWatchPlan; + reportPath: string; +} + +export class TestsWatcher { + private _app: RefreshTarget; + private _plan: TestsWatchPlan; + private _reportPath: string; + private _testsWatcher?: chokidar.FSWatcher; + private _directoriesWatcher?: chokidar.FSWatcher; + private _refreshInProgress = false; + private _queuedFiles = new Set(); + private _queuedRemovedDirectories = new Set(); + private _debounceFiles = new Set(); + private _debounceRemovedDirectories = new Set(); + private _refreshTimer?: NodeJS.Timeout; + private _refreshSequence = 0; + private _firstDebouncedEventAt?: number; + + static create(options: TestsWatcherOptions): TestsWatcher { + return new TestsWatcher(options); + } + + constructor({app, plan, reportPath}: TestsWatcherOptions) { + this._app = app; + this._plan = plan; + this._reportPath = reportPath; + } + + start(): void { + try { + this._testsWatcher = chokidar.watch(this._plan.paths, { + cwd: process.cwd(), + ignoreInitial: true, + ignored: [ + /(^|[/\\])\../, + /(^|[/\\])node_modules([/\\]|$)/, + path.resolve(process.cwd(), '.tests/case.ts'), + path.resolve(process.cwd(), this._reportPath) + ], + awaitWriteFinish: {stabilityThreshold: 200, pollInterval: 100} + }); + this._testsWatcher.on('all', this._queueFileSystemEvent); + this._testsWatcher.on('error', error => this._handleWatcherError('test files', error)); + + // A file glob does not necessarily subscribe Chokidar to directory + // lifecycle events. Watch the non-glob roots separately so deleting a + // directory is always observable. + this._directoriesWatcher = chokidar.watch(this._plan.roots, { + cwd: process.cwd(), + ignoreInitial: true, + ignored: [/(^|[/\\])\../, /(^|[/\\])node_modules([/\\]|$)/] + }); + this._directoriesWatcher.on('unlinkDir', changedDirectory => { + this._queueFileSystemEvent('unlinkDir', changedDirectory); + }); + this._directoriesWatcher.on('error', error => this._handleWatcherError('test directories', error)); + } catch (error) { + this._handleWatcherError('initialization', error); + } + } + + close(): void { + if (this._refreshTimer) { + clearTimeout(this._refreshTimer); + } + void this._testsWatcher?.close(); + void this._directoriesWatcher?.close(); + } + + private _queueFileSystemEvent = (event: string, changedFile: string): void => { + logger.log(`[watch-perf][server] chokidar event ${JSON.stringify({event, path: changedFile})}`); + if (this._debounceFiles.size === 0) { + this._firstDebouncedEventAt = performance.now(); + } + this._debounceFiles.add(changedFile); + if (event === 'unlinkDir') { + this._debounceRemovedDirectories.add(changedFile); + } + if (this._refreshTimer) { + clearTimeout(this._refreshTimer); + } + this._refreshTimer = setTimeout(() => { + this._refreshTimer = undefined; + logger.log(`[watch-perf][server] chokidar debounce: ${this._firstDebouncedEventAt === undefined ? 0 : (performance.now() - this._firstDebouncedEventAt).toFixed(1)}ms ${JSON.stringify({events: this._debounceFiles.size})}`); + this._firstDebouncedEventAt = undefined; + const changedFiles = [...this._debounceFiles]; + const removedDirectories = [...this._debounceRemovedDirectories]; + this._debounceFiles.clear(); + this._debounceRemovedDirectories.clear(); + void this._refresh(changedFiles, removedDirectories); + }, 100); + }; + + private async _refresh(changedFiles: string[], removedDirectories: string[]): Promise { + changedFiles.forEach(file => this._queuedFiles.add(path.resolve(process.cwd(), file))); + removedDirectories.forEach(directory => this._queuedRemovedDirectories.add(path.resolve(process.cwd(), directory))); + + if (this._refreshInProgress) { + return; + } + + this._refreshInProgress = true; + try { + while (this._queuedFiles.size) { + const refreshId = ++this._refreshSequence; + const serverStartedAt = Date.now(); + const refreshStartedAt = performance.now(); + const files = [...this._queuedFiles]; + const removedDirs = [...this._queuedRemovedDirectories]; + this._queuedFiles.clear(); + this._queuedRemovedDirectories.clear(); + let changed = false; + let treeUpdate: TestsTreeUpdate | undefined; + logger.log(`[watch-perf][server][#${refreshId}] refresh started ${JSON.stringify({files: files.length, removedDirectories: removedDirs.length})}`); + await this._app.refreshTestsIfChanged(files, removedDirs, (hasChanges) => { + changed = hasChanges; + if (hasChanges) { + this._app.sendClientEvent(ClientEvents.TESTS_REFRESH_STARTED, {performanceId: refreshId}); + } + }, (update) => { + treeUpdate = update; + }, refreshId); + + if (changed && treeUpdate) { + treeUpdate.performance = { + id: refreshId, + serverStartedAt, + serverCompletedAt: Date.now() + }; + const sendStartedAt = performance.now(); + this._app.sendClientEvent(ClientEvents.TESTS_REFRESHED, treeUpdate); + logger.log(`[watch-perf][server][#${refreshId}] serialize/write SSE: ${(performance.now() - sendStartedAt).toFixed(1)}ms`); + } + logger.log(`[watch-perf][server][#${refreshId}] refresh loop total: ${(performance.now() - refreshStartedAt).toFixed(1)}ms ${JSON.stringify({changed})}`); + } + } catch (error) { + this._app.sendClientEvent(ClientEvents.TESTS_REFRESH_FAILED, undefined); + logger.error(`Error while refreshing tests after file change: ${(error as Error).message}`); + } finally { + this._refreshInProgress = false; + if (this._queuedFiles.size) { + void this._refresh([], []); + } + } + } + + private _handleWatcherError(scope: string, error: unknown): void { + this._app.sendClientEvent(ClientEvents.TESTS_REFRESH_FAILED, undefined); + logger.error(`Test tree watcher error (${scope}): ${(error as Error).message}`); + } +} diff --git a/lib/gui/tool-runner/index.ts b/lib/gui/tool-runner/index.ts index 8bbd1d520..a394493c1 100644 --- a/lib/gui/tool-runner/index.ts +++ b/lib/gui/tool-runner/index.ts @@ -55,6 +55,11 @@ export type ToolRunnerTree = GuiReportBuilderResult & Pick }; +export type TestsTreeUpdate = TreePatch | { + replacement: ToolRunnerTree; + performance?: TreePatch['performance']; +}; + export interface UndoAcceptImagesResult { updatedImages: TreeImage[]; removedResults: string[]; @@ -74,6 +79,15 @@ const logWatchPerformance = (id: number, operation: string, startedAt: number, d const isNoTestsFoundError = (error: unknown): boolean => error instanceof Error && error.message.startsWith('There are no tests found'); +const getTestStructureSignature = (test: TestAdapter): string => JSON.stringify([ + test.browserId, + path.resolve(test.file), + test.titlePath, + test.disabled, + test.silentlySkipped, + test.pending +]); + export class ToolRunner { private _testFiles: string[]; private _toolAdapter: ToolAdapter; @@ -191,7 +205,7 @@ export class ToolRunner { changedFiles: string[], removedDirectories: string[], onChanged: (changed: boolean) => void, - onUpdated: (patch: TreePatch) => void, + onUpdated: (update: TestsTreeUpdate) => void, performanceId: number ): Promise { const totalStartedAt = performance.now(); @@ -209,9 +223,8 @@ export class ToolRunner { } } const isChangedFile = (test: TestAdapter): boolean => affectedFiles.has(path.resolve(test.file)); - const signature = (test: TestAdapter): string => JSON.stringify([test.browserId, path.resolve(test.file), test.titlePath]); const currentTests = [...affectedFiles].flatMap(file => this._testsByFile.get(file) ?? []); - const current = currentTests.map(signature).sort(); + const current = currentTests.map(getTestStructureSignature).sort(); logWatchPerformance(performanceId, 'prepare affected files and current signatures', stageStartedAt, { changedFiles: changedFiles.length, removedDirectories: removedDirectories.length, @@ -226,16 +239,34 @@ export class ToolRunner { let next: string[] = []; let changedCollection: TestCollectionAdapter = {tests: []}; + if (this._ensureTestCollection().hasFocusedTests) { + await this._refreshTestsFromFullCollection(onChanged, onUpdated, performanceId, totalStartedAt); + + return; + } + if (filesToRead.length) { try { stageStartedAt = performance.now(); changedCollection = await this._toolAdapter.readTests(filesToRead, this._globalOpts); logWatchPerformance(performanceId, 'read changed files', stageStartedAt, {tests: changedCollection.tests.length}); + if (changedCollection.hasFocusedTests) { + await this._refreshTestsFromFullCollection(onChanged, onUpdated, performanceId, totalStartedAt); + + return; + } + stageStartedAt = performance.now(); - next = changedCollection.tests.filter(isChangedFile).map(signature).sort(); + next = changedCollection.tests.filter(isChangedFile).map(getTestStructureSignature).sort(); logWatchPerformance(performanceId, 'build changed files signatures', stageStartedAt, {tests: next.length}); } catch (error) { + if (this._toolAdapter.hasFocusedTestsInLastRead) { + await this._refreshTestsFromFullCollection(onChanged, onUpdated, performanceId, totalStartedAt); + + return; + } + if (isNoTestsFoundError(error)) { // Testplane throws instead of returning an empty collection // when the changed file no longer contains any tests. @@ -248,12 +279,8 @@ export class ToolRunner { logWatchPerformance(performanceId, 'fallback: read all tests', stageStartedAt, {tests: collection.tests.length}); stageStartedAt = performance.now(); - const allCurrent = this._ensureTestCollection().tests.map(test => - JSON.stringify([test.browserId, path.resolve(test.file), test.titlePath]) - ).sort(); - const allNext = collection.tests.map(test => - JSON.stringify([test.browserId, path.resolve(test.file), test.titlePath]) - ).sort(); + const allCurrent = this._ensureTestCollection().tests.map(getTestStructureSignature).sort(); + const allNext = collection.tests.map(getTestStructureSignature).sort(); logWatchPerformance(performanceId, 'fallback: compare all signatures', stageStartedAt, {current: allCurrent.length, next: allNext.length}); if (_.isEqual(allCurrent, allNext)) { @@ -264,9 +291,10 @@ export class ToolRunner { } onChanged(true); - this._setCollection(collection); const testsToAdd = collection.tests.filter(isChangedFile); + this._validateUniqueFullNames(collection.tests); onUpdated(await this._applyChangedFiles(affectedFiles, currentTests, testsToAdd, performanceId)); + this._setCollection(collection); logWatchPerformance(performanceId, 'total', totalStartedAt); return; @@ -284,15 +312,55 @@ export class ToolRunner { onChanged(true); stageStartedAt = performance.now(); const testsToAdd = changedCollection.tests.filter(isChangedFile); - this._replaceTestsInCollection(affectedFiles, testsToAdd); + const nextTests = this._getTestsAfterReplacement(affectedFiles, testsToAdd); + this._validateChangedTestsUnique(affectedFiles, testsToAdd); logWatchPerformance(performanceId, 'merge changed tests into collection', stageStartedAt, { changedTests: testsToAdd.length, - totalTests: this._ensureTestCollection().tests.length + totalTests: nextTests.length }); onUpdated(await this._applyChangedFiles(affectedFiles, currentTests, testsToAdd, performanceId)); + this._replaceTestsInCollection(affectedFiles, testsToAdd, nextTests); logWatchPerformance(performanceId, 'total', totalStartedAt); } + private async _refreshTestsFromFullCollection( + onChanged: (changed: boolean) => void, + onUpdated: (update: TestsTreeUpdate) => void, + performanceId: number, + totalStartedAt: number + ): Promise { + const stageStartedAt = performance.now(); + let collection: TestCollectionAdapter; + + try { + collection = await this._readTests(); + } catch (error) { + if (!isNoTestsFoundError(error)) { + throw error; + } + + collection = {tests: [], hasFocusedTests: Boolean(this._toolAdapter.hasFocusedTestsInLastRead)}; + } + logWatchPerformance(performanceId, 'read all tests for focused collection', stageStartedAt, {tests: collection.tests.length}); + + onChanged(true); + await this._replaceTestsFromFullCollection(collection); + onUpdated({replacement: this.tree as ToolRunnerTree}); + logWatchPerformance(performanceId, 'total', totalStartedAt); + } + + private async _replaceTestsFromFullCollection(collection: TestCollectionAdapter): Promise { + const reportBuilder = this._ensureReportBuilder(); + + this._setCollection(collection); + reportBuilder.resetTree(); + this._testAdapters = {}; + this._testAdapterIdsByFile.clear(); + + await this._addTestsToTree(collection.tests); + await this._fillTestsTree(reportBuilder.buildTreeFromCurrentDb()); + } + private async _applyChangedFiles( changedFiles: Set, previousTests: TestAdapter[], @@ -302,44 +370,85 @@ export class ToolRunner { let stageStartedAt = performance.now(); const reportBuilder = this._ensureReportBuilder(); const patchScope = this._createTreePatchScope([...previousTests, ...testsToAdd], reportBuilder.testsTree); - const previousTree = snapshotTree(reportBuilder.testsTree, patchScope); - logWatchPerformance(performanceId, 'snapshot previous server tree', stageStartedAt); + const reportBuilderState = reportBuilder.snapshotTestsState(patchScope, changedFiles, [...previousTests, ...testsToAdd]); + const previousTestAdapterIdsByFile = new Map>(); + const previousTestAdapters: Record = {}; - stageStartedAt = performance.now(); - reportBuilder.removeTestsByFiles([...changedFiles]); - logWatchPerformance(performanceId, 'remove affected tests from server tree', stageStartedAt, {files: changedFiles.size}); - - stageStartedAt = performance.now(); for (const changedFile of changedFiles) { - for (const testId of this._testAdapterIdsByFile.get(changedFile) ?? []) { - delete this._testAdapters[testId]; + const adapterIds = this._testAdapterIdsByFile.get(changedFile); + + if (!adapterIds) { + continue; + } + + previousTestAdapterIdsByFile.set(changedFile, new Set(adapterIds)); + for (const adapterId of adapterIds) { + previousTestAdapters[adapterId] = this._testAdapters[adapterId]; } - this._testAdapterIdsByFile.delete(changedFile); } - logWatchPerformance(performanceId, 'remove affected test adapters', stageStartedAt, {testsToAdd: testsToAdd.length}); + const previousTree = snapshotTree(reportBuilder.testsTree, patchScope); + logWatchPerformance(performanceId, 'snapshot previous server tree', stageStartedAt); stageStartedAt = performance.now(); - await this._addTestsToTree(testsToAdd); - logWatchPerformance(performanceId, 'add affected tests to server tree', stageStartedAt); + try { + reportBuilder.removeTestsByFiles([...changedFiles]); + logWatchPerformance(performanceId, 'remove affected tests from server tree', stageStartedAt, {files: changedFiles.size}); + + stageStartedAt = performance.now(); + for (const changedFile of changedFiles) { + for (const testId of this._testAdapterIdsByFile.get(changedFile) ?? []) { + delete this._testAdapters[testId]; + } + this._testAdapterIdsByFile.delete(changedFile); + } + logWatchPerformance(performanceId, 'remove affected test adapters', stageStartedAt, {testsToAdd: testsToAdd.length}); - stageStartedAt = performance.now(); - reportBuilder.restoreTestHistory(testsToAdd.map(test => ({ - suitePath: test.titlePath, - browserId: test.browserId - }))); - logWatchPerformance(performanceId, 'restore affected tests history', stageStartedAt); + stageStartedAt = performance.now(); + await this._addTestsToTree(testsToAdd); + logWatchPerformance(performanceId, 'add affected tests to server tree', stageStartedAt); - stageStartedAt = performance.now(); - this._extendTreePatchScope(patchScope, testsToAdd, reportBuilder.testsTree); - const patch = createTreePatch(previousTree, reportBuilder.testsTree, patchScope); - logWatchPerformance(performanceId, 'create tree patch', stageStartedAt, { - suites: Object.keys(patch.suites.byId).length, - browsers: Object.keys(patch.browsers.byId).length, - results: Object.keys(patch.results.byId).length, - images: Object.keys(patch.images.byId).length - }); + stageStartedAt = performance.now(); + const historyRestored = reportBuilder.restoreTestHistory(testsToAdd.map(test => ({ + suitePath: test.titlePath, + browserId: test.browserId + }))); + logWatchPerformance(performanceId, 'restore affected tests history', stageStartedAt); - return patch; + if (historyRestored) { + stageStartedAt = performance.now(); + await this._addTestsToTree(testsToAdd.filter(test => !test.pending)); + logWatchPerformance(performanceId, 'restore current runnable test states', stageStartedAt); + } + + stageStartedAt = performance.now(); + reportBuilder.sortTestsTreeBranches(patchScope.suites); + logWatchPerformance(performanceId, 'sort affected tree branches', stageStartedAt, {suites: patchScope.suites.size}); + + stageStartedAt = performance.now(); + this._extendTreePatchScope(patchScope, testsToAdd, reportBuilder.testsTree); + const patch = createTreePatch(previousTree, reportBuilder.testsTree, patchScope); + logWatchPerformance(performanceId, 'create tree patch', stageStartedAt, { + suites: Object.keys(patch.suites.byId).length, + browsers: Object.keys(patch.browsers.byId).length, + results: Object.keys(patch.results.byId).length, + images: Object.keys(patch.images.byId).length + }); + + return patch; + } catch (error) { + reportBuilder.restoreTestsState(reportBuilderState); + for (const changedFile of changedFiles) { + for (const adapterId of this._testAdapterIdsByFile.get(changedFile) ?? []) { + delete this._testAdapters[adapterId]; + } + this._testAdapterIdsByFile.delete(changedFile); + } + Object.assign(this._testAdapters, previousTestAdapters); + for (const [changedFile, adapterIds] of previousTestAdapterIdsByFile) { + this._testAdapterIdsByFile.set(changedFile, adapterIds); + } + throw error; + } } private _createTreePatchScope(tests: TestAdapter[], tree: Tree): TreePatchScope { @@ -392,18 +501,28 @@ export class ToolRunner { } } - private _replaceTestsInCollection(affectedFiles: Set, testsToAdd: TestAdapter[]): void { - affectedFiles.forEach(file => { - for (const test of this._testsByFile.get(file) ?? []) { + private _getTestsAfterReplacement(affectedFiles: Set, testsToAdd: TestAdapter[]): TestAdapter[] { + const testsToRemove = new Set([...affectedFiles].flatMap(file => this._testsByFile.get(file) ?? [])); + + return [ + ...this._ensureTestCollection().tests.filter(test => !testsToRemove.has(test)), + ...testsToAdd + ]; + } + + private _replaceTestsInCollection(affectedFiles: Set, testsToAdd: TestAdapter[], nextTests: TestAdapter[]): void { + for (const affectedFile of affectedFiles) { + for (const test of this._testsByFile.get(affectedFile) ?? []) { this._testFileBySpec.delete(this._getTestSpecKey(test.browserId, test.fullName)); } - this._testsByFile.delete(file); - }); + this._testsByFile.delete(affectedFile); + } for (const test of testsToAdd) { if (!test.file) { continue; } + const testFile = path.resolve(test.file); const tests = this._testsByFile.get(testFile) ?? []; @@ -412,10 +531,47 @@ export class ToolRunner { this._testFileBySpec.set(this._getTestSpecKey(test.browserId, test.fullName), testFile); } - this._collection = {tests: [...this._testsByFile.values()].flat()}; + this._collection = {tests: nextTests}; this._collectionNeedsFullRead = true; } + private _validateChangedTestsUnique(affectedFiles: Set, tests: TestAdapter[]): void { + const changedTestsByFullName = new Map(); + + for (const test of tests) { + const key = this._getTestSpecKey(test.browserId, test.fullName); + const duplicate = changedTestsByFullName.get(key); + const existingFile = this._testFileBySpec.get(key); + + if (duplicate) { + throw this._createDuplicateTestError(test.fullName, duplicate.file, test.file); + } + if (existingFile && !affectedFiles.has(existingFile)) { + throw this._createDuplicateTestError(test.fullName, existingFile, test.file); + } + + changedTestsByFullName.set(key, test); + } + } + + private _validateUniqueFullNames(tests: TestAdapter[]): void { + const testsByFullName = new Map(); + + for (const test of tests) { + const key = this._getTestSpecKey(test.browserId, test.fullName); + const duplicate = testsByFullName.get(key); + + if (duplicate) { + throw this._createDuplicateTestError(test.fullName, duplicate.file, test.file); + } + testsByFullName.set(key, test); + } + } + + private _createDuplicateTestError(fullName: string, firstFile: string, secondFile: string): Error { + return new Error(`Tests with the same title '${fullName}' in files '${path.relative(process.cwd(), firstFile)}' and '${path.relative(process.cwd(), secondFile)}' can't be used`); + } + private _getTestSpecKey(browserId: string, fullName: string): string { return JSON.stringify([browserId, fullName]); } diff --git a/lib/report-builder/gui.ts b/lib/report-builder/gui.ts index e5affc7cf..e63a58e87 100644 --- a/lib/report-builder/gui.ts +++ b/lib/report-builder/gui.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; import {StaticReportBuilder, StaticReportBuilderOptions} from './static'; -import {GuiTestsTreeBuilder, TestBranch, TestEqualDiffsData, TestRefUpdateData} from '../tests-tree-builder/gui'; +import {GuiTestsTreeBuilder, GuiTestsTreeBuilderState, TestBranch, TestEqualDiffsData, TestRefUpdateData} from '../tests-tree-builder/gui'; import {UPDATED, DB_COLUMNS, TestStatus, DEFAULT_TITLE_DELIMITER, SKIPPED, SUCCESS} from '../constants'; import {ConfigForStaticFile, getConfigForStaticFile} from '../server-utils'; import {ReporterTestResult} from '../adapters/test-result'; @@ -11,6 +11,8 @@ import {HtmlReporterValues} from '../plugin-api'; import {StaticTestsTreeBuilder, SkipItem} from '../tests-tree-builder/static'; import {copyAndUpdate} from '../adapters/test-result/utils'; import type {TestHistorySpec} from '../sqlite-client'; +import type {TestAttemptManagerSnapshot} from '../test-attempt-manager'; +import type {TreePatchScope} from '../tests-tree-builder/tree-patch'; interface UndoAcceptImageResult { updatedImage: TreeImage | undefined; @@ -21,6 +23,12 @@ interface UndoAcceptImageResult { newResult: ReporterTestResult; } +export interface GuiReportBuilderTestsState { + treeState: GuiTestsTreeBuilderState; + skips: SkipItem[]; + attempts?: TestAttemptManagerSnapshot; +} + export interface GuiReportBuilderResult { tree: Tree; skips: SkipItem[]; @@ -87,20 +95,54 @@ export class GuiReportBuilder extends StaticReportBuilder { return this._testsTree.tree; } + snapshotTestsState(scope?: TreePatchScope, files?: Iterable, tests?: Iterable<{fullName: string; browserId: string}>): GuiReportBuilderTestsState { + return { + treeState: this._testsTree.snapshotState(scope, files), + skips: [...this._skips], + attempts: tests && this._testAttemptManager.snapshot(tests) + }; + } + + restoreTestsState({treeState, skips, attempts}: GuiReportBuilderTestsState): void { + this._testsTree.restoreState(treeState); + this._skips = skips; + + if (attempts) { + this._testAttemptManager.restore(attempts); + + return; + } + + this.resetAttemps(); + + Object.values(treeState.tree.results.byId).forEach(result => { + this._testAttemptManager.registerAttempt({ + fullName: result.suitePath.join(DEFAULT_TITLE_DELIMITER), + browserId: result.name + }, result.status, result.attempt); + }); + } + removeTestsByFiles(files: string[]): void { this._testsTree.removeTestsByFiles(files); } - restoreTestHistory(tests: TestHistorySpec[]): void { + sortTestsTreeBranches(suiteIds: Iterable): void { + this._testsTree.sortBranches(suiteIds); + } + + restoreTestHistory(tests: TestHistorySpec[]): boolean { const rows = this._dbClient.getSuitesByTests(tests); if (!rows.length) { - return; + return false; } const testsTreeBuilder = StaticTestsTreeBuilder.create({baseHost: this._reporterConfig.baseHost}); const {tree} = testsTreeBuilder.build(rows); this.reuseTestsTree(tree, {replaceCurrentResults: true}); + + return true; } buildTreeFromCurrentDb(): Tree { diff --git a/lib/sqlite-client.ts b/lib/sqlite-client.ts index b4ed355d1..33960ba20 100644 --- a/lib/sqlite-client.ts +++ b/lib/sqlite-client.ts @@ -2,11 +2,10 @@ import path from 'path'; import type {Database, Statement} from '@gemini-testing/sql.js'; import makeDebug from 'debug'; import fs from 'fs-extra'; -import _ from 'lodash'; import NestedError from 'nested-error-stacks'; import {getShortMD5} from './common-utils'; -import {TestStatus, DB_SUITES_TABLE_NAME, SUITES_TABLE_COLUMNS, LOCAL_DATABASE_NAME, DATABASE_URLS_JSON_NAME, DB_CURRENT_VERSION} from './constants'; +import {TestStatus, DB_SUITES_TABLE_NAME, SUITES_TABLE_COLUMNS, LOCAL_DATABASE_NAME, DATABASE_URLS_JSON_NAME, DB_CURRENT_VERSION, DB_COLUMN_INDEXES} from './constants'; import {createTablesQuery, selectAllSuitesQuery, compareDatabaseRowsByTimestamp} from './db-utils/common'; import {setDatabaseVersion} from './db-utils/migrations'; import type {Attachment, ImageInfoFull, TestError, TestStepCompressed, RawSuitesRow} from './types'; @@ -179,23 +178,26 @@ export class SqliteClient { } getSuitesByTests(tests: TestHistorySpec[]): RawSuitesRow[] { + if (!tests.length) { + return []; + } + const rows: RawSuitesRow[] = []; - const uniqueTests = _.uniqBy(tests, ({suitePath, browserId}) => `${JSON.stringify(suitePath)}\0${browserId}`); + const requestedTests = new Set(tests.map(test => `${JSON.stringify(test.suitePath)}\0${test.browserId}`)); + const statement = this._db.prepare(selectAllSuitesQuery()); + + while (statement.step()) { + const row = statement.get(); - for (const {suitePath, browserId} of uniqueTests) { - const statement = this._db.prepare( - `SELECT * FROM ${DB_SUITES_TABLE_NAME} WHERE suitePath = ? AND name = ?` - ); - statement.bind([JSON.stringify(suitePath), browserId]); + if (Array.isArray(row)) { + const key = `${row[DB_COLUMN_INDEXES.suitePath]}\0${row[DB_COLUMN_INDEXES.name]}`; - while (statement.step()) { - const row = statement.get(); - if (Array.isArray(row)) { + if (requestedTests.has(key)) { rows.push(row as RawSuitesRow); } } - statement.free(); } + statement.free(); return rows.sort(compareDatabaseRowsByTimestamp); } diff --git a/lib/static/modules/reducers/tree/index.js b/lib/static/modules/reducers/tree/index.js index 53399a654..961b9f70c 100644 --- a/lib/static/modules/reducers/tree/index.js +++ b/lib/static/modules/reducers/tree/index.js @@ -22,7 +22,7 @@ import {applyStateUpdate, ensureDiffProperty, getUpdatedProperty} from '../../ut import {changeNodeState, getStaticAccepterStateNameImages, resolveUpdatedStatuses, updateImagesStatus} from './helpers'; import * as staticImageAccepter from '../../static-image-accepter'; import {CHECKED, UNCHECKED} from '@/constants/checked-statuses'; -import {initSearch, patchSearch} from '@/static/modules/search'; +import {initSearch} from '@/static/modules/search'; export default ((state, action) => { const diff = {tree: {}}; @@ -79,10 +79,6 @@ export default ((state, action) => { tree.lastPatch = patch; logStage('apply normalized tree patch', stageStartedAt); - stageStartedAt = performance.now(); - patchSearch(tree, patch, action.payload.performance?.id); - logStage('patch search index', stageStartedAt); - stageStartedAt = performance.now(); patch.results.addedIds.forEach((resultId) => { changeResultState({tree, resultId, state: {matchedSelectedGroup: false}}); diff --git a/lib/static/modules/search/index.ts b/lib/static/modules/search/index.ts index 45892abf8..3fb3f90ed 100644 --- a/lib/static/modules/search/index.ts +++ b/lib/static/modules/search/index.ts @@ -2,10 +2,41 @@ import {setMatchCaseFilter, setSearchLoading, updateNameFilter} from '@/static/m import {Tree} from '@/tests-tree-builder/base'; import type {TreePatch} from '@/tests-tree-builder/tree-patch'; import {AttachmentType, TagsAttachment} from '@/types'; +import type {SearchWorkerRequest, SearchWorkerResponse} from './types'; -let worker: Worker; +let worker: Worker | undefined; let searchResult: Set = new Set([]); let searchResultPosition: Map = new Map([]); +let nextRequestId = 0; +const pendingSearches = new Map void>(); +const pendingUpdates = new Map void>(); + +const postMessage = (message: SearchWorkerRequest): void => worker?.postMessage(message); + +const handleWorkerMessage = (event: MessageEvent): void => { + const {requestId} = event.data; + + if (event.data.type === 'search-result') { + pendingSearches.get(requestId)?.(event.data.data); + pendingSearches.delete(requestId); + } else { + pendingUpdates.get(requestId)?.(); + pendingUpdates.delete(requestId); + } +}; + +const handleWorkerError = (): void => { + worker = undefined; + pendingSearches.forEach(resolve => resolve([])); + pendingSearches.clear(); + pendingUpdates.forEach(resolve => resolve()); + pendingUpdates.clear(); +}; + +const waitForUpdate = (message: SearchWorkerRequest & {type: 'init' | 'patch'}): Promise => new Promise(resolve => { + pendingUpdates.set(message.requestId, resolve); + postMessage(message); +}); const getResultTags = (result: Tree['results']['byId'][string]): string[] => { const tagsAttachment = result.attachments?.find(attachment => attachment.type === AttachmentType.Tags) as TagsAttachment; @@ -13,7 +44,7 @@ const getResultTags = (result: Tree['results']['byId'][string]): string[] => { return tagsAttachment ? tagsAttachment.list.map(tag => tag.title) : []; }; -export const initSearch = (tree: Tree, performanceId?: number): void => { +export const initSearch = (tree: Tree, performanceId?: number): Promise => { const list = tree.results.allIds; const idTagMap: Record = {}; @@ -24,24 +55,33 @@ export const initSearch = (tree: Tree, performanceId?: number): void => { }); if (typeof Worker !== 'undefined') { - worker?.terminate(); + const previousWorker = worker; + if (previousWorker) { + handleWorkerError(); + previousWorker.terminate(); + } worker = new Worker( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore /* webpackChunkName: "search-worker" */ new URL('./worker.ts', import.meta.url) ); - worker.postMessage({type: 'init', data: idTagMap, performanceId}); + worker.onmessage = handleWorkerMessage; + worker.onerror = handleWorkerError; + const requestId = ++nextRequestId; + + return waitForUpdate({type: 'init', requestId, data: idTagMap, performanceId}); } + + return Promise.resolve(); }; -export const patchSearch = (tree: Tree, patch: TreePatch, performanceId?: number): void => { +export const patchSearch = (tree: Tree, patch: TreePatch, performanceId?: number): Promise => { if (typeof Worker === 'undefined') { - return; + return Promise.resolve(); } if (!worker) { - initSearch(tree, performanceId); - return; + return initSearch(tree, performanceId); } const affectedBrowserIds = new Set([ @@ -60,8 +100,11 @@ export const patchSearch = (tree: Tree, patch: TreePatch, performanceId?: number } }); - worker.postMessage({ + const requestId = ++nextRequestId; + + return waitForUpdate({ type: 'patch', + requestId, data: { removeIds: [...patch.browsers.removedIds, ...affectedBrowserIds], idTagMap @@ -79,32 +122,26 @@ export const search = ( useRegexFilter = false, updateMatchCase: boolean, dispatch: (action: unknown) => void -): void => { +): Promise => { dispatch(setSearchLoading(true)); - new Promise((resolve: (list: string[]) => void) => { + return new Promise((resolve: (list: string[]) => void) => { if (useRegexFilter) { resolve([]); return; } if (worker) { - worker.postMessage({ + const requestId = ++nextRequestId; + pendingSearches.set(requestId, resolve); + postMessage({ type: 'search', + requestId, data: { text, matchCase } }); - - worker.onmessage = (event: MessageEvent): void => { - resolve(event.data); - }; - - worker.onerror = (): void => { - console.error(`Error while searching ${text}`); - resolve([]); - }; } else { resolve([]); } @@ -134,3 +171,22 @@ export const search = ( dispatch(setSearchLoading(false)); }); }; + +interface SearchOptions { + text: string; + matchCase: boolean; + useRegexFilter: boolean; +} + +export const refreshSearch = async ( + tree: Tree, + patch: TreePatch, + getOptions: () => SearchOptions, + dispatch: (action: unknown) => void, + performanceId?: number +): Promise => { + await patchSearch(tree, patch, performanceId); + const {text, matchCase, useRegexFilter} = getOptions(); + + await search(text, matchCase, useRegexFilter, false, dispatch); +}; diff --git a/lib/static/modules/search/types.ts b/lib/static/modules/search/types.ts new file mode 100644 index 000000000..097033d1c --- /dev/null +++ b/lib/static/modules/search/types.ts @@ -0,0 +1,30 @@ +export type SearchWorkerRequest = { + type: 'init'; + requestId: number; + data: Record; + performanceId?: number; +} | { + type: 'patch'; + requestId: number; + data: { + removeIds: string[]; + idTagMap: Record; + }; + performanceId?: number; +} | { + type: 'search'; + requestId: number; + data: { + text: string; + matchCase: boolean; + }; +}; + +export type SearchWorkerResponse = { + type: 'ready' | 'patched'; + requestId: number; +} | { + type: 'search-result'; + requestId: number; + data: string[]; +}; diff --git a/lib/static/modules/search/worker.ts b/lib/static/modules/search/worker.ts index 67f1ec804..db16caf9e 100644 --- a/lib/static/modules/search/worker.ts +++ b/lib/static/modules/search/worker.ts @@ -2,6 +2,7 @@ import Fuse from 'fuse.js'; import type {Expression} from 'fuse.js'; import {keyboardLayoutConverter} from '@/static/modules/utils'; +import type {SearchWorkerRequest, SearchWorkerResponse} from './types'; type Element = {title: string}; @@ -73,30 +74,7 @@ const search = (testNameFilter: string, matchCase = false): string[] => { } }; -type InitMessage = { - type: 'init'; - data: Record; - performanceId?: number; -} - -type SearchMessage = { - type: 'search'; - data: { - text: string; - matchCase: boolean; - }; -} - -type PatchMessage = { - type: 'patch'; - data: { - removeIds: string[]; - idTagMap: Record; - }; - performanceId?: number; -} - -self.onmessage = (event: MessageEvent): void => { +self.onmessage = (event: MessageEvent): void => { switch (event.data.type) { case 'init': { const startedAt = performance.now(); @@ -106,7 +84,7 @@ self.onmessage = (event: MessageEvent { + eventSource.addEventListener(ClientEvents.TESTS_REFRESHED, async (e) => { const handlerStartedAt = performance.now(); let performanceId: number | string = '?'; try { @@ -97,8 +98,38 @@ function Gui(): ReactNode { }); if (data) { const dispatchStartedAt = performance.now(); - store.dispatch(patchTestsTree(data)); + if (data.replacement) { + const {db} = store.getState(); + + store.dispatch(initGuiReport({...data.replacement, db, isNewUi: true})); + } else { + store.dispatch(patchTestsTree(data)); + } console.info(`[watch-perf][client][#${performanceId}] Redux dispatch including selectors: ${(performance.now() - dispatchStartedAt).toFixed(1)}ms`); + + const getSearchOptions = (): {text: string; matchCase: boolean; useRegexFilter: boolean} => { + const {app: filters} = store.getState(); + + return { + text: filters.nameFilter || '', + matchCase: Boolean(filters.useMatchCaseFilter), + useRegexFilter: Boolean(filters.useRegexFilter) + }; + }; + + if (data.replacement) { + const {text, matchCase, useRegexFilter} = getSearchOptions(); + + await search(text, matchCase, useRegexFilter, false, store.dispatch); + } else { + await refreshSearch( + store.getState().tree, + data, + getSearchOptions, + store.dispatch, + typeof performanceId === 'number' ? performanceId : undefined + ); + } } } finally { console.info(`[watch-perf][client][#${performanceId}] refreshed handler total: ${(performance.now() - handlerStartedAt).toFixed(1)}ms`); diff --git a/lib/test-attempt-manager.ts b/lib/test-attempt-manager.ts index 98ffcf839..6c428c2b0 100644 --- a/lib/test-attempt-manager.ts +++ b/lib/test-attempt-manager.ts @@ -7,6 +7,8 @@ interface AttemptData { statuses: TestStatus[]; } +export type TestAttemptManagerSnapshot = Map; + export class TestAttemptManager { private _attempts: Map; @@ -46,6 +48,31 @@ export class TestAttemptManager { return Math.max(data.statuses.length - 1, 0); } + snapshot(testSpecs: Iterable): TestAttemptManagerSnapshot { + const snapshot: TestAttemptManagerSnapshot = new Map(); + + for (const testSpec of testSpecs) { + const hash = this._getHash(testSpec); + const data = this._attempts.get(hash); + + if (!snapshot.has(hash)) { + snapshot.set(hash, data ? {statuses: [...data.statuses]} : undefined); + } + } + + return snapshot; + } + + restore(snapshot: TestAttemptManagerSnapshot): void { + for (const [hash, data] of snapshot) { + if (data) { + this._attempts.set(hash, {statuses: [...data.statuses]}); + } else { + this._attempts.delete(hash); + } + } + } + private _getHash(testResult: TestSpec): string { return `${testResult.fullName}.${testResult.browserId}`; } diff --git a/lib/tests-tree-builder/gui.ts b/lib/tests-tree-builder/gui.ts index 0159b8e28..7ee131e8a 100644 --- a/lib/tests-tree-builder/gui.ts +++ b/lib/tests-tree-builder/gui.ts @@ -5,6 +5,7 @@ import {TestStatus, UPDATED} from '../constants'; import {isUpdatedStatus} from '../common-utils'; import {ImageFile, ImageInfoWithState} from '../types'; import type {ReporterTestResult} from '../adapters/test-result'; +import type {TreePatchScope} from './tree-patch'; interface SuiteBranch { id: string; @@ -33,6 +34,13 @@ export interface TestRefUpdateData { export type TestEqualDiffsData = TreeImage & { browserName: string }; +export interface GuiTestsTreeBuilderState { + tree: Tree; + browserIdsByFile: Map>; + scope?: TreePatchScope; + files?: string[]; +} + interface TestUndoRefUpdateData { imageId: string; status: TestStatus; @@ -45,6 +53,95 @@ interface TestUndoRefUpdateData { export class GuiTestsTreeBuilder extends BaseTestsTreeBuilder { private _browserIdsByFile = new Map>(); + snapshotState(scope?: TreePatchScope, files?: Iterable): GuiTestsTreeBuilderState { + const getEntries = (byId: Record, ids?: Set): [string, T][] => ids + ? [...ids].flatMap(id => byId[id] ? [[id, byId[id]] as [string, T]] : []) + : Object.entries(byId); + const suitesById = Object.fromEntries(getEntries(this._tree.suites.byId, scope?.suites).map(([id, suite]) => [id, { + ...suite, + suitePath: [...suite.suitePath], + suiteIds: suite.suiteIds && [...suite.suiteIds], + browserIds: suite.browserIds && [...suite.browserIds] + }])); + const normalizedFiles = files && [...files].map(file => path.resolve(file)); + const browserIdsByFileEntries = normalizedFiles + ? normalizedFiles.flatMap(file => this._browserIdsByFile.has(file) ? [[file, this._browserIdsByFile.get(file) as Set] as const] : []) + : [...this._browserIdsByFile]; + + return { + tree: { + suites: { + byId: suitesById, + byHash: Object.fromEntries(Object.values(suitesById).map(suite => [suite.hash, suite])), + allIds: [...this._tree.suites.allIds], + allRootIds: [...this._tree.suites.allRootIds] + }, + browsers: { + byId: Object.fromEntries(getEntries(this._tree.browsers.byId, scope?.browsers).map(([id, browser]) => [id, { + ...browser, + resultIds: [...browser.resultIds] + }])), + allIds: [...this._tree.browsers.allIds] + }, + results: { + byId: Object.fromEntries(getEntries(this._tree.results.byId, scope?.results)), + allIds: [...this._tree.results.allIds] + }, + images: { + byId: Object.fromEntries(getEntries(this._tree.images.byId, scope?.images)), + allIds: [...this._tree.images.allIds] + } + }, + browserIdsByFile: new Map(browserIdsByFileEntries.map(([file, ids]) => [file, new Set(ids)])), + scope, + files: normalizedFiles + }; + } + + restoreState({tree, browserIdsByFile, scope, files}: GuiTestsTreeBuilderState): void { + if (!scope) { + this._tree = tree; + this._browserIdsByFile = browserIdsByFile; + + return; + } + + const restoreById = (current: Record, previous: Record, ids: Set): void => { + for (const id of ids) { + if (previous[id]) { + current[id] = previous[id]; + } else { + delete current[id]; + } + } + }; + + for (const suiteId of scope.suites) { + const currentSuite = this._tree.suites.byId[suiteId]; + + if (currentSuite) { + delete this._tree.suites.byHash[currentSuite.hash]; + } + } + restoreById(this._tree.suites.byId, tree.suites.byId, scope.suites); + Object.assign(this._tree.suites.byHash, tree.suites.byHash); + restoreById(this._tree.browsers.byId, tree.browsers.byId, scope.browsers); + restoreById(this._tree.results.byId, tree.results.byId, scope.results); + restoreById(this._tree.images.byId, tree.images.byId, scope.images); + this._tree.suites.allIds = tree.suites.allIds; + this._tree.suites.allRootIds = tree.suites.allRootIds; + this._tree.browsers.allIds = tree.browsers.allIds; + this._tree.results.allIds = tree.results.allIds; + this._tree.images.allIds = tree.images.allIds; + + for (const file of files ?? []) { + this._browserIdsByFile.delete(file); + } + for (const [file, ids] of browserIdsByFile) { + this._browserIdsByFile.set(file, ids); + } + } + addTestResult(formattedResult: ReporterTestResult): void { super.addTestResult(formattedResult); @@ -62,11 +159,128 @@ export class GuiTestsTreeBuilder extends BaseTestsTreeBuilder { removeTestsByFiles(files: string[]): void { const normalizedFiles = new Set(files.map(file => path.resolve(file))); - const browserIds = _.uniq([...normalizedFiles].flatMap(file => [...this._browserIdsByFile.get(file) ?? []])); + const browserIds = new Set([...normalizedFiles].flatMap(file => [...this._browserIdsByFile.get(file) ?? []])); + const resultIds = new Set(); + const imageIds = new Set(); + const affectedSuiteIds = new Set(); + + for (const browserId of browserIds) { + const browser = this._tree.browsers.byId[browserId]; + + if (!browser) { + continue; + } + + affectedSuiteIds.add(browser.parentId); + for (const resultId of browser.resultIds.filter(Boolean)) { + resultIds.add(resultId); + this._tree.results.byId[resultId]?.imageIds.forEach(imageId => imageIds.add(imageId)); + } + delete this._tree.browsers.byId[browserId]; + } + + for (const resultId of resultIds) { + delete this._tree.results.byId[resultId]; + } + for (const imageId of imageIds) { + delete this._tree.images.byId[imageId]; + } + + this._tree.browsers.allIds = this._tree.browsers.allIds.filter(id => !browserIds.has(id)); + this._tree.results.allIds = this._tree.results.allIds.filter(id => !resultIds.has(id)); + this._tree.images.allIds = this._tree.images.allIds.filter(id => !imageIds.has(id)); + + for (const suiteId of affectedSuiteIds) { + const suite = this._tree.suites.byId[suiteId]; + + if (suite?.browserIds) { + suite.browserIds = suite.browserIds.filter(id => !browserIds.has(id)); + } + } + + this._pruneEmptySuitesOrUpdateStatuses(affectedSuiteIds); - browserIds.forEach(browserId => this._removeBrowser(browserId)); normalizedFiles.forEach(file => this._browserIdsByFile.delete(file)); - this.sortTree(); + } + + sortBranches(suiteIds: Iterable): void { + let shouldSortRootIds = false; + + for (const suiteId of suiteIds) { + const suite = this._tree.suites.byId[suiteId]; + + if (!suite) { + continue; + } + + shouldSortRootIds ||= suite.root; + suite.suiteIds?.sort(); + suite.browserIds?.sort(); + } + + if (shouldSortRootIds) { + this._tree.suites.allRootIds.sort(); + } + } + + private _pruneEmptySuitesOrUpdateStatuses(affectedSuiteIds: Set): void { + const suiteIdsToRemove = new Set(); + const candidateSuiteIds = new Set(affectedSuiteIds); + + for (const affectedSuiteId of affectedSuiteIds) { + let parentId = this._tree.suites.byId[affectedSuiteId]?.parentId; + + while (parentId) { + candidateSuiteIds.add(parentId); + parentId = this._tree.suites.byId[parentId]?.parentId; + } + } + + const deepestFirst = [...candidateSuiteIds].sort((left, right) => + (this._tree.suites.byId[right]?.suitePath.length ?? 0) - (this._tree.suites.byId[left]?.suitePath.length ?? 0)); + + for (const suiteId of deepestFirst) { + const suite = this._tree.suites.byId[suiteId]; + const hasRemainingChildSuite = suite?.suiteIds?.some(childId => !suiteIdsToRemove.has(childId)); + + if (suite && !suite.browserIds?.length && !hasRemainingChildSuite) { + suiteIdsToRemove.add(suiteId); + } + } + + const parentsToFilter = new Set(); + for (const suiteId of suiteIdsToRemove) { + const suite = this._tree.suites.byId[suiteId]; + + if (!suite) { + continue; + } + if (suite.parentId) { + parentsToFilter.add(suite.parentId); + } + delete this._tree.suites.byHash[suite.hash]; + delete this._tree.suites.byId[suiteId]; + } + for (const parentId of parentsToFilter) { + const parent = this._tree.suites.byId[parentId]; + + if (parent?.suiteIds) { + parent.suiteIds = parent.suiteIds.filter(id => !suiteIdsToRemove.has(id)); + } + } + + if (suiteIdsToRemove.size) { + this._tree.suites.allIds = this._tree.suites.allIds.filter(id => !suiteIdsToRemove.has(id)); + this._tree.suites.allRootIds = this._tree.suites.allRootIds.filter(id => !suiteIdsToRemove.has(id)); + } + + for (const suiteId of deepestFirst) { + const suite = this._tree.suites.byId[suiteId]; + + if (suite) { + this._setStatusForBranch(suite.suitePath); + } + } } getImagesInfo(testId: string): TreeImage[] { @@ -217,41 +431,6 @@ export class GuiTestsTreeBuilder extends BaseTestsTreeBuilder { }); } - private _removeBrowser(browserId: string): void { - const browser = this._tree.browsers.byId[browserId]; - if (!browser) { - return; - } - - browser.resultIds.filter(Boolean).forEach(resultId => this.removeTestResult(resultId)); - const suite = this._tree.suites.byId[browser.parentId]; - suite.browserIds = suite.browserIds?.filter(id => id !== browserId); - this._tree.browsers.allIds = this._tree.browsers.allIds.filter(id => id !== browserId); - delete this._tree.browsers.byId[browserId]; - - this._removeEmptySuiteOrUpdateStatus(suite); - } - - private _removeEmptySuiteOrUpdateStatus(suite: TreeSuite): void { - if (suite.browserIds?.length || suite.suiteIds?.length) { - this._setStatusForBranch(suite.suitePath); - return; - } - - const parent = suite.parentId ? this._tree.suites.byId[suite.parentId] : null; - if (parent) { - parent.suiteIds = parent.suiteIds?.filter(id => id !== suite.id); - } - this._tree.suites.allIds = this._tree.suites.allIds.filter(id => id !== suite.id); - this._tree.suites.allRootIds = this._tree.suites.allRootIds.filter(id => id !== suite.id); - delete this._tree.suites.byHash[suite.hash]; - delete this._tree.suites.byId[suite.id]; - - if (parent) { - this._removeEmptySuiteOrUpdateStatus(parent); - } - } - private _reuseBrowser(testsTree: Tree, browserId: string, replaceCurrentResults: boolean): void { const reuseBrowser = testsTree.browsers.byId[browserId]; diff --git a/test/unit/lib/adapters/test-collection/testplane.ts b/test/unit/lib/adapters/test-collection/testplane.ts index 9fe2b115a..a7a6dee3b 100644 --- a/test/unit/lib/adapters/test-collection/testplane.ts +++ b/test/unit/lib/adapters/test-collection/testplane.ts @@ -34,4 +34,13 @@ describe('lib/adapters/test-collection/testplane', () => { assert.deepEqual(testCollectionAdapter.tests, [testAdapter1, testAdapter2]); }); }); + + describe('hasFocusedTests', () => { + it('should return whether mocha focused the collection with "only"', () => { + const testCollection = stubTestCollection() as TestCollection; + + assert.isTrue(TestplaneTestCollectionAdapter.create(testCollection, undefined, true).hasFocusedTests); + assert.isFalse(TestplaneTestCollectionAdapter.create(testCollection).hasFocusedTests); + }); + }); }); diff --git a/test/unit/lib/adapters/tool/testplane/index.ts b/test/unit/lib/adapters/tool/testplane/index.ts index ebf8a67e2..31bdd7e1f 100644 --- a/test/unit/lib/adapters/tool/testplane/index.ts +++ b/test/unit/lib/adapters/tool/testplane/index.ts @@ -182,6 +182,31 @@ describe('lib/adapters/tool/testplane/index', () => { })); }); + it('should mark collection as focused when mocha "only" is used', async () => { + const testplane = stubTool(); + const originalDescribe = globalThis.describe; + const describe = Object.assign(sandbox.stub(), {only: sandbox.stub()}); + const globals = globalThis as typeof globalThis & {describe: typeof describe}; + globals.describe = describe; + testplane.readTests.callsFake(async () => { + testplane.emit('beforeFileRead'); + globals.describe.only('focused suite', () => undefined); + + return stubTestCollection(); + }); + const toolAdapter = TestplaneToolAdapter.create({toolName: ToolName.Testplane, tool: testplane, reporterConfig: {} as ReporterConfig}); + + try { + const collection = await toolAdapter.readTests([], {} as CommanderStatic); + + assert.isTrue(collection.hasFocusedTests); + assert.calledOnceWith(describe.only, 'focused suite', sinon.match.func); + assert.equal(globals.describe.only, describe.only); + } finally { + globalThis.describe = originalDescribe; + } + }); + describe('"replMode" option', () => { it('should be disabled by default', async () => { const testplane = stubTool(); @@ -245,6 +270,59 @@ describe('lib/adapters/tool/testplane/index', () => { }); }); + describe('getTestsWatchPlan', () => { + it('should use explicit cli paths instead of all configured set globs', () => { + const testplane = stubTool(stubConfig({sets: { + unit: {files: ['tests/unit/**/*.ts']}, + integration: {files: ['tests/integration/**/*.ts']} + }})); + const toolAdapter = TestplaneToolAdapter.create({toolName: ToolName.Testplane, tool: testplane, reporterConfig: {} as ReporterConfig}); + + const plan = toolAdapter.getTestsWatchPlan(['tests/unit/example.ts'], {} as CommanderStatic); + + assert.deepEqual(plan, { + paths: ['tests/unit/example.ts'], + roots: ['tests/unit'] + }); + }); + + it('should only use globs from cli-selected sets', () => { + const testplane = stubTool(stubConfig({sets: { + unit: {files: ['tests/unit/**/*.ts']}, + integration: {files: ['tests/integration/**/*.ts']} + }})); + const toolAdapter = TestplaneToolAdapter.create({toolName: ToolName.Testplane, tool: testplane, reporterConfig: {} as ReporterConfig}); + + const plan = toolAdapter.getTestsWatchPlan([], {set: ['unit']} as unknown as CommanderStatic); + + assert.deepEqual(plan, { + paths: ['tests/unit/**/*.ts'], + roots: ['tests/unit'] + }); + }); + + it('should use Testplane default paths when sets do not contain files', () => { + const testplane = stubTool(stubConfig({sets: {'': {files: []}}})); + const toolAdapter = TestplaneToolAdapter.create({toolName: ToolName.Testplane, tool: testplane, reporterConfig: {} as ReporterConfig}); + + const plan = toolAdapter.getTestsWatchPlan([], {} as CommanderStatic); + + assert.deepEqual(plan, { + paths: ['testplane', 'hermione'], + roots: ['testplane', 'hermione'] + }); + }); + + it('should use a directory itself as its watch root', () => { + const testplane = stubTool(stubConfig({sets: {all: {files: ['tests']}}})); + const toolAdapter = TestplaneToolAdapter.create({toolName: ToolName.Testplane, tool: testplane, reporterConfig: {} as ReporterConfig}); + + const plan = toolAdapter.getTestsWatchPlan([], {} as CommanderStatic); + + assert.deepEqual(plan.roots, ['tests']); + }); + }); + describe('run', () => { let collection: TestCollection; let runner: {run: SinonStub}; diff --git a/test/unit/lib/gui/tests-watcher/index.js b/test/unit/lib/gui/tests-watcher/index.js new file mode 100644 index 000000000..dd651b201 --- /dev/null +++ b/test/unit/lib/gui/tests-watcher/index.js @@ -0,0 +1,74 @@ +'use strict'; + +const {EventEmitter} = require('events'); +const proxyquire = require('proxyquire'); + +const {ClientEvents} = require('lib/gui/constants'); +const {logger} = require('lib/common-utils'); + +describe('lib/gui/tests-watcher', () => { + const sandbox = sinon.createSandbox(); + let chokidar; + let watchers; + let TestsWatcher; + let app; + + beforeEach(() => { + watchers = [new EventEmitter(), new EventEmitter()]; + watchers.forEach(watcher => { + watcher.close = sandbox.stub().resolves(); + }); + chokidar = {watch: sandbox.stub()}; + chokidar.watch.onFirstCall().returns(watchers[0]); + chokidar.watch.onSecondCall().returns(watchers[1]); + TestsWatcher = proxyquire('lib/gui/tests-watcher', {chokidar}).TestsWatcher; + app = { + refreshTestsIfChanged: sandbox.stub().resolves(), + sendClientEvent: sandbox.stub() + }; + sandbox.stub(logger, 'error'); + }); + + afterEach(() => sandbox.restore()); + + it('should subscribe to paths and directory roots from adapter watch plan', () => { + const watcher = TestsWatcher.create({ + app, + plan: {paths: ['tests/**/*.ts'], roots: ['tests']}, + reportPath: 'html-report' + }); + + watcher.start(); + + assert.calledWith(chokidar.watch.firstCall, ['tests/**/*.ts']); + assert.calledWith(chokidar.watch.secondCall, ['tests']); + }); + + it('should report a contextual error without throwing from chokidar event', () => { + const watcher = TestsWatcher.create({ + app, + plan: {paths: ['tests/**/*.ts'], roots: ['tests']}, + reportPath: 'html-report' + }); + watcher.start(); + + watchers[0].emit('error', new Error('permission denied')); + + assert.calledOnceWith(app.sendClientEvent, ClientEvents.TESTS_REFRESH_FAILED, undefined); + assert.calledOnceWith(logger.error, 'Test tree watcher error (test files): permission denied'); + }); + + it('should close both file system watchers', () => { + const watcher = TestsWatcher.create({ + app, + plan: {paths: ['tests/**/*.ts'], roots: ['tests']}, + reportPath: 'html-report' + }); + watcher.start(); + + watcher.close(); + + assert.calledOnce(watchers[0].close); + assert.calledOnce(watchers[1].close); + }); +}); diff --git a/test/unit/lib/gui/tool-runner/index.js b/test/unit/lib/gui/tool-runner/index.js index e2cfcf9db..25fc242a4 100644 --- a/test/unit/lib/gui/tool-runner/index.js +++ b/test/unit/lib/gui/tool-runner/index.js @@ -9,7 +9,7 @@ const {LOCAL_DATABASE_NAME} = require('lib/constants/database'); const {logger} = require('lib/common-utils'); const {stubToolAdapter, stubConfig, stubReporterConfig, mkImagesInfo, mkState, mkSuite} = require('test/unit/utils'); const {SqliteClient} = require('lib/sqlite-client'); -const {PluginEvents, TestStatus, UPDATED} = require('lib/constants'); +const {PluginEvents, TestStatus, UPDATED, IDLE} = require('lib/constants'); const {Cache} = require('lib/cache'); const {TestplaneTestAdapter} = require('lib/adapters/test/testplane'); const {TestplaneConfigAdapter} = require('lib/adapters/config/testplane'); @@ -469,6 +469,103 @@ describe('lib/gui/tool-runner/index', () => { }]); }); + it('should read all files when "only" is added and remove tests from other files', async () => { + const focusedFile = '/ref/cwd/focused.hermione.ts'; + const otherFile = '/ref/cwd/other.hermione.ts'; + const focusedTest = mkTestAdapter_(stubTest_({file: focusedFile, browserId: 'yabro', fullTitle: () => 'focused test'})); + const otherTest = mkTestAdapter_(stubTest_({file: otherFile, browserId: 'yabro', fullTitle: () => 'other test'})); + const tree = { + suites: {byId: {}, byHash: {}, allIds: [], allRootIds: []}, + browsers: {byId: {}, allIds: []}, + results: {byId: {}, allIds: []}, + images: {byId: {}, allIds: []} + }; + toolAdapter.readTests.onFirstCall().resolves({tests: [focusedTest, otherTest], hasFocusedTests: false}); + toolAdapter.readTests.onSecondCall().resolves({tests: [focusedTest], hasFocusedTests: true}); + toolAdapter.readTests.onThirdCall().resolves({tests: [focusedTest], hasFocusedTests: true}); + sandbox.stub(fs, 'pathExists').withArgs(focusedFile).resolves(true); + sandbox.stub(reportBuilder, 'testsTree').get(() => tree); + const gui = initGuiReporter({toolAdapter, paths: ['tests/**/*.ts']}); + const onChanged = sandbox.stub(); + const onUpdated = sandbox.stub(); + + await gui.initialize(); + await gui.refreshTestsIfChanged([focusedFile], [], onChanged, onUpdated, 1); + + assert.callCount(toolAdapter.readTests, 3); + assert.deepEqual(toolAdapter.readTests.secondCall.args[0], [focusedFile]); + assert.deepEqual(toolAdapter.readTests.thirdCall.args[0], ['tests/**/*.ts']); + assert.calledOnceWith(onChanged, true); + assert.calledOnce(reportBuilder.resetTree); + assert.property(onUpdated.firstCall.args[0], 'replacement'); + assert.notCalled(reportBuilder.removeTestsByFiles); + }); + + it('should read all files when "only" is removed and restore tests from other files', async () => { + const focusedFile = '/ref/cwd/focused.hermione.ts'; + const otherFile = '/ref/cwd/other.hermione.ts'; + const focusedTest = mkTestAdapter_(stubTest_({file: focusedFile, browserId: 'yabro', fullTitle: () => 'focused test'})); + const otherTest = mkTestAdapter_(stubTest_({file: otherFile, browserId: 'yabro', fullTitle: () => 'other test'})); + const tree = { + suites: {byId: {}, byHash: {}, allIds: [], allRootIds: []}, + browsers: {byId: {}, allIds: []}, + results: {byId: {}, allIds: []}, + images: {byId: {}, allIds: []} + }; + toolAdapter.readTests.onFirstCall().resolves({tests: [focusedTest], hasFocusedTests: true}); + toolAdapter.readTests.onSecondCall().resolves({tests: [focusedTest, otherTest], hasFocusedTests: false}); + sandbox.stub(fs, 'pathExists').withArgs(focusedFile).resolves(true); + sandbox.stub(reportBuilder, 'testsTree').get(() => tree); + const gui = initGuiReporter({toolAdapter, paths: ['tests/**/*.ts']}); + const onChanged = sandbox.stub(); + const onUpdated = sandbox.stub(); + + await gui.initialize(); + await gui.refreshTestsIfChanged([focusedFile], [], onChanged, onUpdated, 1); + + assert.callCount(toolAdapter.readTests, 2); + assert.deepEqual(toolAdapter.readTests.secondCall.args[0], ['tests/**/*.ts']); + assert.calledOnceWith(onChanged, true); + assert.calledOnce(reportBuilder.resetTree); + assert.property(onUpdated.firstCall.args[0], 'replacement'); + assert.notCalled(reportBuilder.removeTestsByFiles); + }); + + it('should remove all tests when an empty focused suite is added', async () => { + const focusedFile = '/ref/cwd/focused.hermione.ts'; + const otherFile = '/ref/cwd/other.hermione.ts'; + const focusedTest = mkTestAdapter_(stubTest_({file: focusedFile, browserId: 'yabro', fullTitle: () => 'focused test'})); + const otherTest = mkTestAdapter_(stubTest_({file: otherFile, browserId: 'yabro', fullTitle: () => 'other test'})); + const noTestsError = new Error('There are no tests found. Try to specify options'); + const tree = { + suites: {byId: {}, byHash: {}, allIds: [], allRootIds: []}, + browsers: {byId: {}, allIds: []}, + results: {byId: {}, allIds: []}, + images: {byId: {}, allIds: []} + }; + toolAdapter.hasFocusedTestsInLastRead = false; + toolAdapter.readTests.onFirstCall().resolves({tests: [focusedTest, otherTest], hasFocusedTests: false}); + toolAdapter.readTests.onSecondCall().callsFake(async () => { + toolAdapter.hasFocusedTestsInLastRead = true; + throw noTestsError; + }); + toolAdapter.readTests.onThirdCall().rejects(noTestsError); + sandbox.stub(fs, 'pathExists').withArgs(focusedFile).resolves(true); + sandbox.stub(reportBuilder, 'testsTree').get(() => tree); + const gui = initGuiReporter({toolAdapter, paths: ['tests/**/*.ts']}); + const onChanged = sandbox.stub(); + const onUpdated = sandbox.stub(); + + await gui.initialize(); + await gui.refreshTestsIfChanged([focusedFile], [], onChanged, onUpdated, 1); + + assert.callCount(toolAdapter.readTests, 3); + assert.calledOnceWith(onChanged, true); + assert.calledOnce(reportBuilder.resetTree); + assert.property(onUpdated.firstCall.args[0], 'replacement'); + assert.notCalled(reportBuilder.removeTestsByFiles); + }); + it('should treat a changed file with no tests as an empty partial collection', async () => { const changedFile = '/ref/cwd/changed.hermione.ts'; const oldTest = mkTestAdapter_(stubTest_({ @@ -516,6 +613,107 @@ describe('lib/gui/tool-runner/index', () => { assert.callCount(toolAdapter.readTests, 3); assert.deepEqual(toolAdapter.readTests.thirdCall.args[0], [changedFile]); }); + + ['pending', 'disabled', 'silentSkip'].forEach(property => { + it(`should refresh tree when test "${property}" state changes`, async () => { + const changedFile = '/ref/cwd/changed.hermione.ts'; + const oldTest = mkTestAdapter_(stubTest_({file: changedFile, browserId: 'yabro', [property]: false})); + const newTest = mkTestAdapter_(stubTest_({file: changedFile, browserId: 'yabro', [property]: true})); + const tree = { + suites: {byId: {}, byHash: {}, allIds: [], allRootIds: []}, + browsers: {byId: {}, allIds: []}, + results: {byId: {}, allIds: []}, + images: {byId: {}, allIds: []} + }; + toolAdapter.readTests.onFirstCall().resolves({tests: [oldTest]}); + toolAdapter.readTests.onSecondCall().resolves({tests: [newTest]}); + sandbox.stub(fs, 'pathExists').withArgs(changedFile).resolves(true); + sandbox.stub(reportBuilder, 'testsTree').get(() => tree); + const gui = initGuiReporter({toolAdapter}); + const onChanged = sandbox.stub(); + + await gui.initialize(); + await gui.refreshTestsIfChanged([changedFile], [], onChanged, sandbox.stub(), 1); + + assert.calledOnceWith(onChanged, true); + }); + }); + + it('should keep current idle state after removing pending from a test with skipped history', async () => { + const changedFile = '/ref/cwd/changed.hermione.ts'; + const skippedTest = mkTestAdapter_(stubTest_({file: changedFile, browserId: 'yabro', pending: true})); + const activeTest = mkTestAdapter_(stubTest_({file: changedFile, browserId: 'yabro', pending: false})); + const tree = { + suites: {byId: {}, byHash: {}, allIds: [], allRootIds: []}, + browsers: {byId: {}, allIds: []}, + results: {byId: {}, allIds: []}, + images: {byId: {}, allIds: []} + }; + toolAdapter.readTests.onFirstCall().resolves({tests: [skippedTest]}); + toolAdapter.readTests.onSecondCall().resolves({tests: [activeTest]}); + sandbox.stub(fs, 'pathExists').withArgs(changedFile).resolves(true); + sandbox.stub(reportBuilder, 'testsTree').get(() => tree); + reportBuilder.restoreTestHistory.returns(true); + const gui = initGuiReporter({toolAdapter}); + + await gui.initialize(); + reportBuilder.addTestResult.resetHistory(); + await gui.refreshTestsIfChanged([changedFile], [], sandbox.stub(), sandbox.stub(), 1); + + assert.calledTwice(reportBuilder.addTestResult); + assert.equal(reportBuilder.addTestResult.firstCall.args[0].status, IDLE); + assert.equal(reportBuilder.addTestResult.secondCall.args[0].status, IDLE); + }); + + it('should reject a duplicate full name introduced by a partial read', async () => { + const existingFile = '/ref/cwd/existing.hermione.ts'; + const changedFile = '/ref/cwd/changed.hermione.ts'; + const existingTest = mkTestAdapter_(stubTest_({file: existingFile, browserId: 'yabro', fullTitle: () => 'duplicate'})); + const oldChangedTest = mkTestAdapter_(stubTest_({file: changedFile, browserId: 'yabro', fullTitle: () => 'old'})); + const duplicateTest = mkTestAdapter_(stubTest_({file: changedFile, browserId: 'yabro', fullTitle: () => 'duplicate'})); + toolAdapter.readTests.onFirstCall().resolves({tests: [existingTest, oldChangedTest]}); + toolAdapter.readTests.onSecondCall().resolves({tests: [duplicateTest]}); + sandbox.stub(fs, 'pathExists').withArgs(changedFile).resolves(true); + const gui = initGuiReporter({toolAdapter}); + + await gui.initialize(); + + await assert.isRejected( + gui.refreshTestsIfChanged([changedFile], [], sandbox.stub(), sandbox.stub(), 1), + /Tests with the same title 'duplicate'/ + ); + assert.notCalled(reportBuilder.removeTestsByFiles); + }); + + it('should restore report builder state when applying a patch fails', async () => { + const changedFile = '/ref/cwd/changed.hermione.ts'; + const oldTest = mkTestAdapter_(stubTest_({file: changedFile, browserId: 'yabro', fullTitle: () => 'old'})); + const newTest = mkTestAdapter_(stubTest_({file: changedFile, browserId: 'yabro', fullTitle: () => 'new'})); + const tree = { + suites: {byId: {}, byHash: {}, allIds: [], allRootIds: []}, + browsers: {byId: {}, allIds: []}, + results: {byId: {}, allIds: []}, + images: {byId: {}, allIds: []} + }; + const snapshot = {treeState: {tree, browserIdsByFile: new Map()}, skips: []}; + toolAdapter.readTests.onFirstCall().resolves({tests: [oldTest]}); + toolAdapter.readTests.onSecondCall().resolves({tests: [newTest]}); + sandbox.stub(fs, 'pathExists').withArgs(changedFile).resolves(true); + sandbox.stub(reportBuilder, 'testsTree').get(() => tree); + reportBuilder.snapshotTestsState.returns(snapshot); + reportBuilder.restoreTestHistory.throws(new Error('history failed')); + const gui = initGuiReporter({toolAdapter}); + + await gui.initialize(); + + await assert.isRejected( + gui.refreshTestsIfChanged([changedFile], [], sandbox.stub(), sandbox.stub(), 1), + /history failed/ + ); + assert.calledOnceWith(reportBuilder.restoreTestsState, snapshot); + assert.equal(gui._testAdapters['some-id'], oldTest); + assert.deepEqual([...gui._testAdapterIdsByFile.get(changedFile)], ['some-id']); + }); }); describe('findEqualDiffs', () => { diff --git a/test/unit/lib/sqlite-client.js b/test/unit/lib/sqlite-client.js index d388f9135..a084b2e35 100644 --- a/test/unit/lib/sqlite-client.js +++ b/test/unit/lib/sqlite-client.js @@ -89,12 +89,30 @@ describe('lib/sqlite-client', () => { db.run(`INSERT INTO suites VALUES (${placeholders})`, mkRow(['suite', 'first'], 'chrome', 2)); db.run(`INSERT INTO suites VALUES (${placeholders})`, mkRow(['suite', 'second'], 'chrome', 1)); + db.run(`INSERT INTO suites VALUES (${placeholders})`, mkRow(['suite', 'first'], 'chrome', 3)); + db.run(`INSERT INTO suites VALUES (${placeholders})`, mkRow(['suite', 'other'], 'chrome', 0)); + const prepare = sandbox.spy(db, 'prepare'); + + const rows = client.getSuitesByTests([ + {suitePath: ['suite', 'first'], browserId: 'chrome'}, + {suitePath: ['suite', 'second'], browserId: 'chrome'} + ]); + + assert.lengthOf(rows, 3); + assert.equal(rows[0][0], JSON.stringify(['suite', 'second'])); + assert.equal(rows[0][13], 1); + assert.equal(rows[2][13], 3); + assert.calledOnceWith(prepare, 'SELECT * FROM suites'); + client.close(); + }); - const rows = client.getSuitesByTests([{suitePath: ['suite', 'first'], browserId: 'chrome'}]); + it('should not scan suites when no tests are requested', async () => { + const client = await makeSqliteClient_(); + const db = client.getRawConnection(); + const prepare = sandbox.spy(db, 'prepare'); - assert.lengthOf(rows, 1); - assert.equal(rows[0][0], JSON.stringify(['suite', 'first'])); - assert.equal(rows[0][13], 2); + assert.deepEqual(client.getSuitesByTests([]), []); + assert.notCalled(prepare); client.close(); }); diff --git a/test/unit/lib/static/modules/search/index.js b/test/unit/lib/static/modules/search/index.js new file mode 100644 index 000000000..64e60352c --- /dev/null +++ b/test/unit/lib/static/modules/search/index.js @@ -0,0 +1,101 @@ +'use strict'; + +const proxyquire = require('proxyquire'); + +describe('lib/static/modules/search', () => { + let originalWorker; + let workers; + let searchModule; + + class FakeWorker { + constructor() { + this.messages = []; + this.terminate = sinon.stub(); + workers.push(this); + } + + postMessage(message) { + this.messages.push(message); + } + + respond(message) { + this.onmessage({data: message}); + } + } + + const mkTree_ = () => ({ + suites: {byId: {}, byHash: {}, allIds: [], allRootIds: []}, + browsers: {byId: {}, allIds: []}, + results: {byId: {}, allIds: []}, + images: {byId: {}, allIds: []} + }); + + const mkPatch_ = () => ({ + affectedRootIds: [], + affectedSuiteIds: [], + suites: {addedIds: [], removedIds: [], byId: {}, allRootIds: []}, + browsers: {addedIds: [], removedIds: [], byId: {}}, + results: {addedIds: [], removedIds: [], byId: {}}, + images: {addedIds: [], removedIds: [], byId: {}} + }); + + beforeEach(() => { + originalWorker = global.Worker; + workers = []; + global.Worker = FakeWorker; + searchModule = proxyquire.noPreserveCache()('lib/static/modules/search', {}); + }); + + afterEach(() => { + global.Worker = originalWorker; + }); + + it('should not confuse a patch acknowledgement with a search result', async () => { + const initPromise = searchModule.initSearch(mkTree_()); + const worker = workers[0]; + const initMessage = worker.messages[0]; + worker.respond({type: 'ready', requestId: initMessage.requestId}); + await initPromise; + + const dispatch = sinon.stub(); + let searchResolved = false; + const searchPromise = searchModule.search('new test', false, false, false, dispatch) + .then(() => searchResolved = true); + const searchMessage = worker.messages.at(-1); + const patchPromise = searchModule.patchSearch(mkTree_(), mkPatch_()); + const patchMessage = worker.messages.at(-1); + + worker.respond({type: 'patched', requestId: patchMessage.requestId}); + await patchPromise; + + assert.isFalse(searchResolved); + worker.respond({type: 'search-result', requestId: searchMessage.requestId, data: ['new test chrome']}); + await searchPromise; + assert.isTrue(searchModule.checkSearchResultExits('new test chrome')); + }); + + it('should rerun the current filter after patching the search index', async () => { + const initPromise = searchModule.initSearch(mkTree_()); + const worker = workers[0]; + worker.respond({type: 'ready', requestId: worker.messages[0].requestId}); + await initPromise; + + const dispatch = sinon.stub(); + const refreshPromise = searchModule.refreshSearch( + mkTree_(), + mkPatch_(), + () => ({text: 'renamed', matchCase: false, useRegexFilter: false}), + dispatch + ); + const patchMessage = worker.messages.at(-1); + worker.respond({type: 'patched', requestId: patchMessage.requestId}); + await Promise.resolve(); + const searchMessage = worker.messages.at(-1); + + assert.equal(searchMessage.type, 'search'); + assert.equal(searchMessage.data.text, 'renamed'); + worker.respond({type: 'search-result', requestId: searchMessage.requestId, data: ['renamed chrome']}); + await refreshPromise; + assert.isTrue(searchModule.checkSearchResultExits('renamed chrome')); + }); +}); diff --git a/test/unit/lib/test-attempt-manager.js b/test/unit/lib/test-attempt-manager.js new file mode 100644 index 000000000..fbff7106f --- /dev/null +++ b/test/unit/lib/test-attempt-manager.js @@ -0,0 +1,22 @@ +'use strict'; + +const {TestAttemptManager} = require('lib/test-attempt-manager'); +const {FAIL, SUCCESS} = require('lib/constants'); + +describe('TestAttemptManager', () => { + it('should restore attempts only for snapshotted tests', () => { + const manager = new TestAttemptManager(); + const existing = {fullName: 'existing test', browserId: 'chrome'}; + const added = {fullName: 'added test', browserId: 'chrome'}; + manager.registerAttempt(existing, SUCCESS); + const snapshot = manager.snapshot([existing, added]); + + manager.registerAttempt(existing, FAIL); + manager.registerAttempt(added, FAIL); + manager.restore(snapshot); + + assert.equal(manager.getCurrentAttempt(existing), 0); + assert.equal(manager.registerAttempt(existing, FAIL), 1); + assert.equal(manager.registerAttempt(added, SUCCESS), 0); + }); +}); diff --git a/test/unit/lib/tests-tree-builder/gui.js b/test/unit/lib/tests-tree-builder/gui.js index cfb744dd4..36df93c36 100644 --- a/test/unit/lib/tests-tree-builder/gui.js +++ b/test/unit/lib/tests-tree-builder/gui.js @@ -281,6 +281,105 @@ describe('GuiResultsTreeBuilder', () => { assert.deepEqual(builder.tree.browsers.allIds, []); assert.deepEqual(builder.tree.results.allIds, []); }); + + it('should prune multiple sibling branches in one batch', () => { + builder.addTestResult(mkFormattedResult_({ + status: IDLE, + file: '/project/first.ts', + testPath: ['root', 'group', 'first'], + browserId: 'chrome' + })); + builder.addTestResult(mkFormattedResult_({ + status: IDLE, + file: '/project/second.ts', + testPath: ['root', 'group', 'second'], + browserId: 'chrome' + })); + builder.addTestResult(mkFormattedResult_({ + status: SUCCESS, + file: '/project/remaining.ts', + testPath: ['root', 'remaining'], + browserId: 'chrome' + })); + + builder.removeTestsByFiles(['/project/first.ts', '/project/second.ts']); + + assert.notExists(builder.tree.suites.byId['root group first']); + assert.notExists(builder.tree.suites.byId['root group second']); + assert.notExists(builder.tree.suites.byId['root group']); + assert.exists(builder.tree.suites.byId['root remaining']); + assert.deepEqual(builder.tree.suites.byId.root.suiteIds, ['root remaining']); + assert.equal(builder.tree.suites.byId.root.status, SUCCESS); + }); + }); + + describe('snapshot and restore', () => { + it('should restore both tree and file index', () => { + const file = '/project/test.ts'; + builder.addTestResult(mkFormattedResult_({ + status: IDLE, + file, + testPath: ['test'], + browserId: 'chrome' + })); + const snapshot = builder.snapshotState(); + + builder.removeTestsByFiles([file]); + builder.restoreState(snapshot); + builder.removeTestsByFiles([file]); + + assert.deepEqual(builder.tree.suites.allIds, []); + assert.deepEqual(builder.tree.browsers.allIds, []); + assert.deepEqual(builder.tree.results.allIds, []); + }); + + it('should restore only scoped branches and remove newly added nodes', () => { + const changedFile = '/project/changed.ts'; + const unchangedFile = '/project/unchanged.ts'; + builder.addTestResult(mkFormattedResult_({ + status: IDLE, + file: changedFile, + testPath: ['root', 'changed'], + browserId: 'chrome' + })); + builder.addTestResult(mkFormattedResult_({ + status: SUCCESS, + file: unchangedFile, + testPath: ['root', 'unchanged'], + browserId: 'chrome' + })); + const scope = { + suites: new Set(['root', 'root changed']), + browsers: new Set(['root changed chrome']), + results: new Set(['root changed chrome 0']), + images: new Set() + }; + const snapshot = builder.snapshotState(scope, [changedFile]); + + builder.removeTestsByFiles([changedFile]); + builder.addTestResult(mkFormattedResult_({ + status: IDLE, + file: changedFile, + testPath: ['root', 'replacement'], + browserId: 'firefox' + })); + scope.suites.add('root replacement'); + scope.browsers.add('root replacement firefox'); + scope.results.add('root replacement firefox 0'); + builder.restoreState(snapshot); + + assert.exists(builder.tree.suites.byId['root changed']); + assert.exists(builder.tree.browsers.byId['root changed chrome']); + assert.exists(builder.tree.results.byId['root changed chrome 0']); + assert.notExists(builder.tree.suites.byId['root replacement']); + assert.notExists(builder.tree.browsers.byId['root replacement firefox']); + assert.notExists(builder.tree.results.byId['root replacement firefox 0']); + assert.exists(builder.tree.suites.byId['root unchanged']); + + builder.removeTestsByFiles([changedFile]); + assert.notExists(builder.tree.suites.byId['root changed']); + assert.exists(builder.tree.suites.byId['root unchanged']); + }); }); describe('"getResultDataToUnacceptImage" method', () => { From b2a1f11b3fe9c990f9af6d47265897cddbed7926 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Tue, 15 Sep 2026 04:10:29 +0700 Subject: [PATCH 4/4] fix: pr last fixes --- lib/gui/app.ts | 5 +- lib/gui/tests-watcher/index.ts | 28 +---- lib/gui/tool-runner/index.ts | 101 +++--------------- lib/report-builder/gui.ts | 35 +++++- .../reducers/new-ui-grouped-tests/index.ts | 6 -- lib/static/modules/reducers/tree/index.js | 15 --- lib/static/modules/search/index.ts | 16 ++- lib/static/modules/search/types.ts | 2 - lib/static/modules/search/worker.ts | 13 --- lib/static/new-ui/app/gui.tsx | 38 +------ .../TreeActionsToolbar/index.module.css | 2 +- .../suites/components/SuitesPage/selectors.ts | 28 ----- lib/test-attempt-manager.ts | 10 ++ lib/tests-tree-builder/tree-patch.ts | 5 - test/unit/lib/gui/tool-runner/index.js | 33 +++--- test/unit/lib/report-builder/gui.js | 37 +++++++ test/unit/lib/test-attempt-manager.js | 22 ++++ 17 files changed, 152 insertions(+), 244 deletions(-) diff --git a/lib/gui/app.ts b/lib/gui/app.ts index 0917d34f3..e1ee26a01 100644 --- a/lib/gui/app.ts +++ b/lib/gui/app.ts @@ -62,10 +62,9 @@ export class App { changedFiles: string[], removedDirectories: string[], onChanged: (changed: boolean) => void, - onUpdated: (update: TestsTreeUpdate) => void, - performanceId: number + onUpdated: (update: TestsTreeUpdate) => void ): Promise { - return this._toolRunner.refreshTestsIfChanged(changedFiles, removedDirectories, onChanged, onUpdated, performanceId); + return this._toolRunner.refreshTestsIfChanged(changedFiles, removedDirectories, onChanged, onUpdated); } addClient(connection: Response): void { diff --git a/lib/gui/tests-watcher/index.ts b/lib/gui/tests-watcher/index.ts index 1ff89fdd8..ebdb9d270 100644 --- a/lib/gui/tests-watcher/index.ts +++ b/lib/gui/tests-watcher/index.ts @@ -1,5 +1,4 @@ import path from 'node:path'; -import {performance} from 'node:perf_hooks'; import chokidar from 'chokidar'; @@ -13,8 +12,7 @@ interface RefreshTarget { changedFiles: string[], removedDirectories: string[], onChanged: (changed: boolean) => void, - onUpdated: (update: TestsTreeUpdate) => void, - performanceId: number + onUpdated: (update: TestsTreeUpdate) => void ): Promise; sendClientEvent(event: string, data: unknown): void; } @@ -37,8 +35,6 @@ export class TestsWatcher { private _debounceFiles = new Set(); private _debounceRemovedDirectories = new Set(); private _refreshTimer?: NodeJS.Timeout; - private _refreshSequence = 0; - private _firstDebouncedEventAt?: number; static create(options: TestsWatcherOptions): TestsWatcher { return new TestsWatcher(options); @@ -92,10 +88,6 @@ export class TestsWatcher { } private _queueFileSystemEvent = (event: string, changedFile: string): void => { - logger.log(`[watch-perf][server] chokidar event ${JSON.stringify({event, path: changedFile})}`); - if (this._debounceFiles.size === 0) { - this._firstDebouncedEventAt = performance.now(); - } this._debounceFiles.add(changedFile); if (event === 'unlinkDir') { this._debounceRemovedDirectories.add(changedFile); @@ -105,8 +97,6 @@ export class TestsWatcher { } this._refreshTimer = setTimeout(() => { this._refreshTimer = undefined; - logger.log(`[watch-perf][server] chokidar debounce: ${this._firstDebouncedEventAt === undefined ? 0 : (performance.now() - this._firstDebouncedEventAt).toFixed(1)}ms ${JSON.stringify({events: this._debounceFiles.size})}`); - this._firstDebouncedEventAt = undefined; const changedFiles = [...this._debounceFiles]; const removedDirectories = [...this._debounceRemovedDirectories]; this._debounceFiles.clear(); @@ -126,36 +116,24 @@ export class TestsWatcher { this._refreshInProgress = true; try { while (this._queuedFiles.size) { - const refreshId = ++this._refreshSequence; - const serverStartedAt = Date.now(); - const refreshStartedAt = performance.now(); const files = [...this._queuedFiles]; const removedDirs = [...this._queuedRemovedDirectories]; this._queuedFiles.clear(); this._queuedRemovedDirectories.clear(); let changed = false; let treeUpdate: TestsTreeUpdate | undefined; - logger.log(`[watch-perf][server][#${refreshId}] refresh started ${JSON.stringify({files: files.length, removedDirectories: removedDirs.length})}`); await this._app.refreshTestsIfChanged(files, removedDirs, (hasChanges) => { changed = hasChanges; if (hasChanges) { - this._app.sendClientEvent(ClientEvents.TESTS_REFRESH_STARTED, {performanceId: refreshId}); + this._app.sendClientEvent(ClientEvents.TESTS_REFRESH_STARTED, undefined); } }, (update) => { treeUpdate = update; - }, refreshId); + }); if (changed && treeUpdate) { - treeUpdate.performance = { - id: refreshId, - serverStartedAt, - serverCompletedAt: Date.now() - }; - const sendStartedAt = performance.now(); this._app.sendClientEvent(ClientEvents.TESTS_REFRESHED, treeUpdate); - logger.log(`[watch-perf][server][#${refreshId}] serialize/write SSE: ${(performance.now() - sendStartedAt).toFixed(1)}ms`); } - logger.log(`[watch-perf][server][#${refreshId}] refresh loop total: ${(performance.now() - refreshStartedAt).toFixed(1)}ms ${JSON.stringify({changed})}`); } } catch (error) { this._app.sendClientEvent(ClientEvents.TESTS_REFRESH_FAILED, undefined); diff --git a/lib/gui/tool-runner/index.ts b/lib/gui/tool-runner/index.ts index a394493c1..53ca2ff5a 100644 --- a/lib/gui/tool-runner/index.ts +++ b/lib/gui/tool-runner/index.ts @@ -1,6 +1,5 @@ import path from 'node:path'; import os from 'node:os'; -import {performance} from 'node:perf_hooks'; import {CommanderStatic} from '@gemini-testing/commander'; import chalk from 'chalk'; @@ -57,7 +56,6 @@ export type ToolRunnerTree = GuiReportBuilderResult & Pick): void => { - const duration = (performance.now() - startedAt).toFixed(1); - const detailsText = details ? ` ${JSON.stringify(details)}` : ''; - - logger.log(`[watch-perf][server][#${id}] ${operation}: ${duration}ms${detailsText}`); -}; - const isNoTestsFoundError = (error: unknown): boolean => error instanceof Error && error.message.startsWith('There are no tests found'); @@ -205,11 +196,8 @@ export class ToolRunner { changedFiles: string[], removedDirectories: string[], onChanged: (changed: boolean) => void, - onUpdated: (update: TestsTreeUpdate) => void, - performanceId: number + onUpdated: (update: TestsTreeUpdate) => void ): Promise { - const totalStartedAt = performance.now(); - let stageStartedAt = performance.now(); const normalizedFiles = new Set(changedFiles.map(file => path.resolve(file))); const normalizedDirectories = removedDirectories.map(directory => path.resolve(directory)); const isInsideRemovedDirectory = (file: string): boolean => normalizedDirectories.some(directory => { @@ -225,44 +213,32 @@ export class ToolRunner { const isChangedFile = (test: TestAdapter): boolean => affectedFiles.has(path.resolve(test.file)); const currentTests = [...affectedFiles].flatMap(file => this._testsByFile.get(file) ?? []); const current = currentTests.map(getTestStructureSignature).sort(); - logWatchPerformance(performanceId, 'prepare affected files and current signatures', stageStartedAt, { - changedFiles: changedFiles.length, - removedDirectories: removedDirectories.length, - affectedFiles: affectedFiles.size, - currentTests: current.length - }); - stageStartedAt = performance.now(); const existingFiles = await Promise.all(changedFiles.map(async file => await fs.pathExists(file) ? file : null)); const filesToRead = existingFiles.filter((file): file is string => Boolean(file)); - logWatchPerformance(performanceId, 'check changed files existence', stageStartedAt, {filesToRead: filesToRead.length}); let next: string[] = []; let changedCollection: TestCollectionAdapter = {tests: []}; if (this._ensureTestCollection().hasFocusedTests) { - await this._refreshTestsFromFullCollection(onChanged, onUpdated, performanceId, totalStartedAt); + await this._refreshTestsFromFullCollection(onChanged, onUpdated); return; } if (filesToRead.length) { try { - stageStartedAt = performance.now(); changedCollection = await this._toolAdapter.readTests(filesToRead, this._globalOpts); - logWatchPerformance(performanceId, 'read changed files', stageStartedAt, {tests: changedCollection.tests.length}); if (changedCollection.hasFocusedTests) { - await this._refreshTestsFromFullCollection(onChanged, onUpdated, performanceId, totalStartedAt); + await this._refreshTestsFromFullCollection(onChanged, onUpdated); return; } - stageStartedAt = performance.now(); next = changedCollection.tests.filter(isChangedFile).map(getTestStructureSignature).sort(); - logWatchPerformance(performanceId, 'build changed files signatures', stageStartedAt, {tests: next.length}); } catch (error) { if (this._toolAdapter.hasFocusedTestsInLastRead) { - await this._refreshTestsFromFullCollection(onChanged, onUpdated, performanceId, totalStartedAt); + await this._refreshTestsFromFullCollection(onChanged, onUpdated); return; } @@ -270,32 +246,25 @@ export class ToolRunner { if (isNoTestsFoundError(error)) { // Testplane throws instead of returning an empty collection // when the changed file no longer contains any tests. - logWatchPerformance(performanceId, 'read changed files (no tests found)', stageStartedAt, {tests: 0}); } else { // If a partial read fails for another reason, use the full // collection to determine whether the tree has changed. - stageStartedAt = performance.now(); const collection = await this._readTests(); - logWatchPerformance(performanceId, 'fallback: read all tests', stageStartedAt, {tests: collection.tests.length}); - stageStartedAt = performance.now(); const allCurrent = this._ensureTestCollection().tests.map(getTestStructureSignature).sort(); const allNext = collection.tests.map(getTestStructureSignature).sort(); - logWatchPerformance(performanceId, 'fallback: compare all signatures', stageStartedAt, {current: allCurrent.length, next: allNext.length}); if (_.isEqual(allCurrent, allNext)) { this._collectionNeedsFullRead = true; onChanged(false); - logWatchPerformance(performanceId, 'total (no structural changes)', totalStartedAt); return; } onChanged(true); const testsToAdd = collection.tests.filter(isChangedFile); this._validateUniqueFullNames(collection.tests); - onUpdated(await this._applyChangedFiles(affectedFiles, currentTests, testsToAdd, performanceId)); + onUpdated(await this._applyChangedFiles(affectedFiles, currentTests, testsToAdd)); this._setCollection(collection); - logWatchPerformance(performanceId, 'total', totalStartedAt); return; } @@ -305,31 +274,21 @@ export class ToolRunner { if (!removedDirectories.length && _.isEqual(current, next)) { this._collectionNeedsFullRead = true; onChanged(false); - logWatchPerformance(performanceId, 'total (no structural changes)', totalStartedAt); return; } onChanged(true); - stageStartedAt = performance.now(); const testsToAdd = changedCollection.tests.filter(isChangedFile); const nextTests = this._getTestsAfterReplacement(affectedFiles, testsToAdd); this._validateChangedTestsUnique(affectedFiles, testsToAdd); - logWatchPerformance(performanceId, 'merge changed tests into collection', stageStartedAt, { - changedTests: testsToAdd.length, - totalTests: nextTests.length - }); - onUpdated(await this._applyChangedFiles(affectedFiles, currentTests, testsToAdd, performanceId)); + onUpdated(await this._applyChangedFiles(affectedFiles, currentTests, testsToAdd)); this._replaceTestsInCollection(affectedFiles, testsToAdd, nextTests); - logWatchPerformance(performanceId, 'total', totalStartedAt); } private async _refreshTestsFromFullCollection( onChanged: (changed: boolean) => void, - onUpdated: (update: TestsTreeUpdate) => void, - performanceId: number, - totalStartedAt: number + onUpdated: (update: TestsTreeUpdate) => void ): Promise { - const stageStartedAt = performance.now(); let collection: TestCollectionAdapter; try { @@ -341,12 +300,9 @@ export class ToolRunner { collection = {tests: [], hasFocusedTests: Boolean(this._toolAdapter.hasFocusedTestsInLastRead)}; } - logWatchPerformance(performanceId, 'read all tests for focused collection', stageStartedAt, {tests: collection.tests.length}); - onChanged(true); await this._replaceTestsFromFullCollection(collection); onUpdated({replacement: this.tree as ToolRunnerTree}); - logWatchPerformance(performanceId, 'total', totalStartedAt); } private async _replaceTestsFromFullCollection(collection: TestCollectionAdapter): Promise { @@ -364,10 +320,8 @@ export class ToolRunner { private async _applyChangedFiles( changedFiles: Set, previousTests: TestAdapter[], - testsToAdd: TestAdapter[], - performanceId: number + testsToAdd: TestAdapter[] ): Promise { - let stageStartedAt = performance.now(); const reportBuilder = this._ensureReportBuilder(); const patchScope = this._createTreePatchScope([...previousTests, ...testsToAdd], reportBuilder.testsTree); const reportBuilderState = reportBuilder.snapshotTestsState(patchScope, changedFiles, [...previousTests, ...testsToAdd]); @@ -387,54 +341,35 @@ export class ToolRunner { } } const previousTree = snapshotTree(reportBuilder.testsTree, patchScope); - logWatchPerformance(performanceId, 'snapshot previous server tree', stageStartedAt); - stageStartedAt = performance.now(); try { reportBuilder.removeTestsByFiles([...changedFiles]); - logWatchPerformance(performanceId, 'remove affected tests from server tree', stageStartedAt, {files: changedFiles.size}); - stageStartedAt = performance.now(); for (const changedFile of changedFiles) { for (const testId of this._testAdapterIdsByFile.get(changedFile) ?? []) { delete this._testAdapters[testId]; } this._testAdapterIdsByFile.delete(changedFile); } - logWatchPerformance(performanceId, 'remove affected test adapters', stageStartedAt, {testsToAdd: testsToAdd.length}); - stageStartedAt = performance.now(); await this._addTestsToTree(testsToAdd); - logWatchPerformance(performanceId, 'add affected tests to server tree', stageStartedAt); - stageStartedAt = performance.now(); - const historyRestored = reportBuilder.restoreTestHistory(testsToAdd.map(test => ({ + const testHistorySpecs = testsToAdd.map(test => ({ suitePath: test.titlePath, browserId: test.browserId - }))); - logWatchPerformance(performanceId, 'restore affected tests history', stageStartedAt); - - if (historyRestored) { - stageStartedAt = performance.now(); - await this._addTestsToTree(testsToAdd.filter(test => !test.pending)); - logWatchPerformance(performanceId, 'restore current runnable test states', stageStartedAt); - } + })); + reportBuilder.restoreTestHistory(testHistorySpecs, { + excludeSkipped: testsToAdd.filter(test => !test.pending).map(test => ({ + suitePath: test.titlePath, + browserId: test.browserId + })) + }); - stageStartedAt = performance.now(); reportBuilder.sortTestsTreeBranches(patchScope.suites); - logWatchPerformance(performanceId, 'sort affected tree branches', stageStartedAt, {suites: patchScope.suites.size}); - stageStartedAt = performance.now(); this._extendTreePatchScope(patchScope, testsToAdd, reportBuilder.testsTree); - const patch = createTreePatch(previousTree, reportBuilder.testsTree, patchScope); - logWatchPerformance(performanceId, 'create tree patch', stageStartedAt, { - suites: Object.keys(patch.suites.byId).length, - browsers: Object.keys(patch.browsers.byId).length, - results: Object.keys(patch.results.byId).length, - images: Object.keys(patch.images.byId).length - }); - return patch; + return createTreePatch(previousTree, reportBuilder.testsTree, patchScope); } catch (error) { reportBuilder.restoreTestsState(reportBuilderState); for (const changedFile of changedFiles) { @@ -767,7 +702,6 @@ export class ToolRunner { let testCollection = this._ensureTestCollection(); if (this._collectionNeedsFullRead) { - const startedAt = performance.now(); const selectedTestFiles = tests.length ? _.uniq(tests.map(test => this._testFileBySpec.get(this._getTestSpecKey(test.browserName, test.testName))).filter((file): file is string => Boolean(file))) : this._testFiles; @@ -777,7 +711,6 @@ export class ToolRunner { if (!tests.length) { this._setCollection(testCollection); } - logger.log(`[watch-perf][server][run] refresh executable test collection: ${(performance.now() - startedAt).toFixed(1)}ms ${JSON.stringify({files: testFiles.length, tests: testCollection.tests.length})}`); } const shouldRunAllTests = _.isEmpty(tests); diff --git a/lib/report-builder/gui.ts b/lib/report-builder/gui.ts index e63a58e87..e066c9c3f 100644 --- a/lib/report-builder/gui.ts +++ b/lib/report-builder/gui.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import {StaticReportBuilder, StaticReportBuilderOptions} from './static'; import {GuiTestsTreeBuilder, GuiTestsTreeBuilderState, TestBranch, TestEqualDiffsData, TestRefUpdateData} from '../tests-tree-builder/gui'; -import {UPDATED, DB_COLUMNS, TestStatus, DEFAULT_TITLE_DELIMITER, SKIPPED, SUCCESS} from '../constants'; +import {UPDATED, DB_COLUMNS, DB_COLUMN_INDEXES, TestStatus, DEFAULT_TITLE_DELIMITER, SKIPPED, SUCCESS} from '../constants'; import {ConfigForStaticFile, getConfigForStaticFile} from '../server-utils'; import {ReporterTestResult} from '../adapters/test-result'; import {Tree, TreeImage} from '../tests-tree-builder/base'; @@ -58,6 +58,28 @@ export class GuiReportBuilder extends StaticReportBuilder { reuseTestsTree(tree: Tree, options?: {replaceCurrentResults?: boolean}): void { this._testsTree.reuseTestsTree(tree, options); + if (options?.replaceCurrentResults) { + for (const browserId of tree.browsers.allIds) { + const browser = this._testsTree.tree.browsers.byId[browserId]; + + if (!browser) { + continue; + } + + const suitePath = this._testsTree.tree.suites.byId[browser.parentId].suitePath; + const statuses = browser.resultIds + .filter(Boolean) + .map(resultId => this._testsTree.tree.results.byId[resultId].status); + + this._testAttemptManager.replaceAttempts({ + fullName: suitePath.join(DEFAULT_TITLE_DELIMITER), + browserId: browser.name + }, statuses); + } + + return; + } + // Fill test attempt manager with data from db for (const [, testResult] of Object.entries(tree.results.byId)) { this._testAttemptManager.registerAttempt({ @@ -131,8 +153,15 @@ export class GuiReportBuilder extends StaticReportBuilder { this._testsTree.sortBranches(suiteIds); } - restoreTestHistory(tests: TestHistorySpec[]): boolean { - const rows = this._dbClient.getSuitesByTests(tests); + restoreTestHistory(tests: TestHistorySpec[], {excludeSkipped = []}: {excludeSkipped?: TestHistorySpec[]} = {}): boolean { + const excludedSkippedTests = new Set(excludeSkipped.map(({suitePath, browserId}) => + `${JSON.stringify(suitePath)}\0${browserId}`)); + const rows = this._dbClient.getSuitesByTests(tests).filter(row => { + const key = `${row[DB_COLUMN_INDEXES.suitePath]}\0${row[DB_COLUMN_INDEXES.name]}`; + + return row[DB_COLUMN_INDEXES.status] !== SKIPPED || !excludedSkippedTests.has(key); + }); + if (!rows.length) { return false; } diff --git a/lib/static/modules/reducers/new-ui-grouped-tests/index.ts b/lib/static/modules/reducers/new-ui-grouped-tests/index.ts index 0d587fd91..122937243 100644 --- a/lib/static/modules/reducers/new-ui-grouped-tests/index.ts +++ b/lib/static/modules/reducers/new-ui-grouped-tests/index.ts @@ -54,21 +54,15 @@ export default (state: State, action: SomeAction): State => { } case actionNames.PATCH_TESTS_TREE: { - const startedAt = performance.now(); - const performanceId = action.payload.performance?.id ?? '?'; const expressionIds = state.app.groupTestsData.currentExpressionIds; if (!expressionIds.length) { - console.info(`[watch-perf][client][#${performanceId}][grouping reducer] skipped: ${(performance.now() - startedAt).toFixed(1)}ms`); return state; } const expressions = expressionIds .map(id => state.app.groupTestsData.availableExpressions.find(expr => expr.id === id) as GroupByExpression); const groupsById = groupTests(expressions, state.tree.results.byId, state.tree.images.byId, state.config.errorPatterns); - console.info(`[watch-perf][client][#${performanceId}][grouping reducer] rebuild groups: ${(performance.now() - startedAt).toFixed(1)}ms`, { - groups: Object.keys(groupsById).length - }); return Object.assign({}, state, { tree: Object.assign({}, state.tree, { diff --git a/lib/static/modules/reducers/tree/index.js b/lib/static/modules/reducers/tree/index.js index 961b9f70c..465070f23 100644 --- a/lib/static/modules/reducers/tree/index.js +++ b/lib/static/modules/reducers/tree/index.js @@ -65,30 +65,20 @@ export default ((state, action) => { } case actionNames.PATCH_TESTS_TREE: { - const reducerStartedAt = performance.now(); - const performanceId = action.payload.performance?.id ?? '?'; - const logStage = (operation, startedAt) => { - console.info(`[watch-perf][client][#${performanceId}][reducer] ${operation}: ${(performance.now() - startedAt).toFixed(1)}ms`); - }; const nextState = produce(state, (draft) => { const {tree, view, app} = draft; const patch = action.payload; - let stageStartedAt = performance.now(); applyTreePatch(tree, patch); tree.lastPatch = patch; - logStage('apply normalized tree patch', stageStartedAt); - stageStartedAt = performance.now(); patch.results.addedIds.forEach((resultId) => { changeResultState({tree, resultId, state: {matchedSelectedGroup: false}}); }); if (patch.images.addedIds.length) { calcImagesOpenness({tree, expand: view.expand, imageIds: patch.images.addedIds}); } - logStage('initialize result/image states', stageStartedAt); - stageStartedAt = performance.now(); const affectedBrowserIds = Object.keys(patch.browsers.byId); affectedBrowserIds.forEach((browserId) => { if (patch.browsers.addedIds.includes(browserId)) { @@ -105,9 +95,7 @@ export default ((state, action) => { } calcBrowsersOpenness({tree, expand: view.expand, browserIds: affectedBrowserIds}); } - logStage('update browser states', stageStartedAt); - stageStartedAt = performance.now(); patch.suites.addedIds.forEach((suiteId) => { changeSuiteState(tree, suiteId, {checkStatus: UNCHECKED}); }); @@ -126,11 +114,8 @@ export default ((state, action) => { calcSuitesOpenness({tree, expand: view.expand, suiteIds: affectedSuiteIds}); } tree.suites.failedRootIds = getFailedRootSuiteIds(tree.suites); - logStage('update suite statuses/states', stageStartedAt); }); - logStage('Immer finalize and reducer total', reducerStartedAt); - return nextState; } diff --git a/lib/static/modules/search/index.ts b/lib/static/modules/search/index.ts index 3fb3f90ed..9335e3b07 100644 --- a/lib/static/modules/search/index.ts +++ b/lib/static/modules/search/index.ts @@ -44,7 +44,7 @@ const getResultTags = (result: Tree['results']['byId'][string]): string[] => { return tagsAttachment ? tagsAttachment.list.map(tag => tag.title) : []; }; -export const initSearch = (tree: Tree, performanceId?: number): Promise => { +export const initSearch = (tree: Tree): Promise => { const list = tree.results.allIds; const idTagMap: Record = {}; @@ -69,19 +69,19 @@ export const initSearch = (tree: Tree, performanceId?: number): Promise => worker.onerror = handleWorkerError; const requestId = ++nextRequestId; - return waitForUpdate({type: 'init', requestId, data: idTagMap, performanceId}); + return waitForUpdate({type: 'init', requestId, data: idTagMap}); } return Promise.resolve(); }; -export const patchSearch = (tree: Tree, patch: TreePatch, performanceId?: number): Promise => { +export const patchSearch = (tree: Tree, patch: TreePatch): Promise => { if (typeof Worker === 'undefined') { return Promise.resolve(); } if (!worker) { - return initSearch(tree, performanceId); + return initSearch(tree); } const affectedBrowserIds = new Set([ @@ -108,8 +108,7 @@ export const patchSearch = (tree: Tree, patch: TreePatch, performanceId?: number data: { removeIds: [...patch.browsers.removedIds, ...affectedBrowserIds], idTagMap - }, - performanceId + } }); }; @@ -182,10 +181,9 @@ export const refreshSearch = async ( tree: Tree, patch: TreePatch, getOptions: () => SearchOptions, - dispatch: (action: unknown) => void, - performanceId?: number + dispatch: (action: unknown) => void ): Promise => { - await patchSearch(tree, patch, performanceId); + await patchSearch(tree, patch); const {text, matchCase, useRegexFilter} = getOptions(); await search(text, matchCase, useRegexFilter, false, dispatch); diff --git a/lib/static/modules/search/types.ts b/lib/static/modules/search/types.ts index 097033d1c..99a4e6e24 100644 --- a/lib/static/modules/search/types.ts +++ b/lib/static/modules/search/types.ts @@ -2,7 +2,6 @@ export type SearchWorkerRequest = { type: 'init'; requestId: number; data: Record; - performanceId?: number; } | { type: 'patch'; requestId: number; @@ -10,7 +9,6 @@ export type SearchWorkerRequest = { removeIds: string[]; idTagMap: Record; }; - performanceId?: number; } | { type: 'search'; requestId: number; diff --git a/lib/static/modules/search/worker.ts b/lib/static/modules/search/worker.ts index db16caf9e..faa00749c 100644 --- a/lib/static/modules/search/worker.ts +++ b/lib/static/modules/search/worker.ts @@ -77,18 +77,11 @@ const search = (testNameFilter: string, matchCase = false): string[] => { self.onmessage = (event: MessageEvent): void => { switch (event.data.type) { case 'init': { - const startedAt = performance.now(); initSearch(event.data.data); - if (event.data.performanceId !== undefined) { - console.info(`[watch-perf][client][#${event.data.performanceId}][search worker] rebuild index: ${(performance.now() - startedAt).toFixed(1)}ms`, { - items: Object.keys(event.data.data).length - }); - } self.postMessage({type: 'ready', requestId: event.data.requestId} satisfies SearchWorkerResponse); break; } case 'patch': { - const startedAt = performance.now(); const removeIds = new Set(event.data.data.removeIds); const preparedItems = Object.entries(event.data.data.idTagMap).map(([title, tags]) => ({ title, @@ -102,12 +95,6 @@ self.onmessage = (event: MessageEvent): void => { fuseMatchCase.add(item); }); - if (event.data.performanceId !== undefined) { - console.info(`[watch-perf][client][#${event.data.performanceId}][search worker] patch index: ${(performance.now() - startedAt).toFixed(1)}ms`, { - removed: removeIds.size, - upserted: preparedItems.length - }); - } self.postMessage({type: 'patched', requestId: event.data.requestId} satisfies SearchWorkerResponse); break; } diff --git a/lib/static/new-ui/app/gui.tsx b/lib/static/new-ui/app/gui.tsx index 66fefa060..8850bca70 100644 --- a/lib/static/new-ui/app/gui.tsx +++ b/lib/static/new-ui/app/gui.tsx @@ -21,7 +21,6 @@ import {refreshSearch, search} from '@/static/modules/search'; const rootEl = document.getElementById('app') as HTMLDivElement; const root = createRoot(rootEl); -const watchRefreshStartedAt = new Map(); function Gui(): ReactNode { const eventSource = useEventSource(); @@ -71,33 +70,16 @@ function Gui(): ReactNode { store.dispatch(setRepeatLeft(data.repeatLeft)); }); - eventSource.addEventListener(ClientEvents.TESTS_REFRESH_STARTED, (e) => { - const {performanceId} = JSON.parse(e.data) as {performanceId: number}; - watchRefreshStartedAt.set(performanceId, performance.now()); - console.info(`[watch-perf][client][#${performanceId}] refresh event started`); + eventSource.addEventListener(ClientEvents.TESTS_REFRESH_STARTED, () => { flushSync(() => { store.dispatch(setRefreshLoading(true)); }); }); eventSource.addEventListener(ClientEvents.TESTS_REFRESHED, async (e) => { - const handlerStartedAt = performance.now(); - let performanceId: number | string = '?'; try { - const parseStartedAt = performance.now(); const data = JSON.parse(e.data); - performanceId = data?.performance?.id ?? '?'; - console.info(`[watch-perf][client][#${performanceId}] JSON.parse: ${(performance.now() - parseStartedAt).toFixed(1)}ms`, { - payloadCharacters: e.data.length, - serverToClient: data?.performance?.serverCompletedAt - ? `${Date.now() - data.performance.serverCompletedAt}ms` - : 'unknown', - serverRefreshStartToClient: data?.performance?.serverStartedAt - ? `${Date.now() - data.performance.serverStartedAt}ms` - : 'unknown' - }); if (data) { - const dispatchStartedAt = performance.now(); if (data.replacement) { const {db} = store.getState(); @@ -105,7 +87,6 @@ function Gui(): ReactNode { } else { store.dispatch(patchTestsTree(data)); } - console.info(`[watch-perf][client][#${performanceId}] Redux dispatch including selectors: ${(performance.now() - dispatchStartedAt).toFixed(1)}ms`); const getSearchOptions = (): {text: string; matchCase: boolean; useRegexFilter: boolean} => { const {app: filters} = store.getState(); @@ -126,31 +107,16 @@ function Gui(): ReactNode { store.getState().tree, data, getSearchOptions, - store.dispatch, - typeof performanceId === 'number' ? performanceId : undefined + store.dispatch ); } } } finally { - console.info(`[watch-perf][client][#${performanceId}] refreshed handler total: ${(performance.now() - handlerStartedAt).toFixed(1)}ms`); - - if (typeof performanceId === 'number') { - const clientStartedAt = watchRefreshStartedAt.get(performanceId); - if (clientStartedAt !== undefined) { - console.info(`[watch-perf][client][#${performanceId}] from refresh-start event to completed handler: ${(performance.now() - clientStartedAt).toFixed(1)}ms`); - } - watchRefreshStartedAt.delete(performanceId); - } store.dispatch(setRefreshLoading(false)); - - requestAnimationFrame(() => requestAnimationFrame(() => { - console.info(`[watch-perf][client][#${performanceId}] event handler + React render + next paint: ${(performance.now() - handlerStartedAt).toFixed(1)}ms`); - })); } }); eventSource.addEventListener(ClientEvents.TESTS_REFRESH_FAILED, () => { - watchRefreshStartedAt.clear(); store.dispatch(setRefreshLoading(false)); }); }; diff --git a/lib/static/new-ui/components/TreeActionsToolbar/index.module.css b/lib/static/new-ui/components/TreeActionsToolbar/index.module.css index e51b4a77c..d7a1c6aff 100644 --- a/lib/static/new-ui/components/TreeActionsToolbar/index.module.css +++ b/lib/static/new-ui/components/TreeActionsToolbar/index.module.css @@ -24,7 +24,7 @@ to { transform: rotate(360deg); } } -.refresh-icon-loading { +.is-refresh-tests-loading { animation: spin 0.8s linear infinite; } diff --git a/lib/static/new-ui/features/suites/components/SuitesPage/selectors.ts b/lib/static/new-ui/features/suites/components/SuitesPage/selectors.ts index ed3d55890..ccf215b65 100644 --- a/lib/static/new-ui/features/suites/components/SuitesPage/selectors.ts +++ b/lib/static/new-ui/features/suites/components/SuitesPage/selectors.ts @@ -82,19 +82,6 @@ const reuseUnaffectedNodes = (newNodes: TreeNode[], previousNodes: TreeNode[], t export const getSuitesTreeViewData = createSelector( [getGroups, getSuites, getAllRootGroupIds, getBrowsers, getBrowsersState, getResults, getImages, getTreeViewMode, getSortTestsData, getBrowsersList, getLastTreePatch], (groups, suites, rootGroupIds, browsers, browsersState, results, images, treeViewMode, sortTestsData, browsersList, treePatch): TreeViewData => { - const selectorStartedAt = performance.now(); - const shouldMeasure = Boolean(treePatch && treePatch !== previousTreePatch); - const performanceId = treePatch?.performance?.id ?? '?'; - const logStage = (operation: string, startedAt: number, details?: Record): void => { - if (!shouldMeasure) { - return; - } - - console.info( - `[watch-perf][client][#${performanceId}][selector] ${operation}: ${(performance.now() - startedAt).toFixed(1)}ms`, - details ?? '' - ); - }; const currentSortDirection = sortTestsData.currentDirection; const currentSortExpression = sortTestsData.availableExpressions .find(expr => expr.id === sortTestsData.currentExpressionIds[0]) @@ -112,20 +99,15 @@ export const getSuitesTreeViewData = createSelector( treeViewMode === TreeViewMode.Tree && currentSortExpression.type === SortType.ByName ) { - let stageStartedAt = performance.now(); const affectedRootIds = new Set(treePatch.affectedRootIds); const unaffectedTreeNodes = previousTreeViewData.tree.filter(node => !affectedRootIds.has(node.data.entityId)); const affectedBrowserIds = collectRootBrowserIds(treePatch.affectedRootIds, suites); const affectedBrowsers = affectedBrowserIds .filter(browserId => browsersState[browserId]?.shouldBeShown) .map(browserId => browsers[browserId]); - logStage('collect affected branch', stageStartedAt, {roots: affectedRootIds.size, browsers: affectedBrowsers.length}); - stageStartedAt = performance.now(); const affectedTreeRoot = buildTreeBottomUp(entitiesContext, affectedBrowsers); - logStage('build affected branch', stageStartedAt); - stageStartedAt = performance.now(); const affectedTreeNodes = reuseUnaffectedNodes( sortTreeNodes(entitiesContext, affectedTreeRoot.children ?? []), previousTreeViewData.tree, @@ -136,21 +118,16 @@ export const getSuitesTreeViewData = createSelector( ...unaffectedTreeNodes, ...affectedTreeNodes ].sort((a, b) => a.data.title.join(' ').localeCompare(b.data.title.join(' ')) * direction); - logStage('sort branch and reuse unchanged nodes', stageStartedAt); - stageStartedAt = performance.now(); const {allTreeNodeIds, visibleTreeNodeIds} = collectTreeLeafIds(treeNodes); - logStage('collect derived tree ids', stageStartedAt, {all: allTreeNodeIds.length, visible: visibleTreeNodeIds.length}); previousTreePatch = treePatch; previousTreeViewData = {tree: treeNodes, allTreeNodeIds, visibleTreeNodeIds}; - logStage('incremental selector total', selectorStartedAt); return previousTreeViewData; } if (isGroupingEnabled) { - const fullBuildStartedAt = performance.now(); const treeNodes = rootGroupIds .map(rootId => { const groupEntity = groups[rootId]; @@ -176,13 +153,10 @@ export const getSuitesTreeViewData = createSelector( allTreeNodeIds, visibleTreeNodeIds }; - logStage('full grouped tree rebuild', fullBuildStartedAt, {all: allTreeNodeIds.length, visible: visibleTreeNodeIds.length}); - logStage('selector total', selectorStartedAt); return previousTreeViewData; } - const fullBuildStartedAt = performance.now(); const suitesTreeRoot = buildTreeBottomUp(entitiesContext, Object.values(browsers).filter(browser => browsersState[browser.id].shouldBeShown)); suitesTreeRoot.children = sortTreeNodes(entitiesContext, suitesTreeRoot.children ?? []); const {allTreeNodeIds, visibleTreeNodeIds} = collectTreeLeafIds([suitesTreeRoot]); @@ -193,8 +167,6 @@ export const getSuitesTreeViewData = createSelector( visibleTreeNodeIds, tree: suitesTreeRoot.children ?? [] }; - logStage('full tree rebuild', fullBuildStartedAt, {all: allTreeNodeIds.length, visible: visibleTreeNodeIds.length}); - logStage('selector total', selectorStartedAt); return previousTreeViewData; }); diff --git a/lib/test-attempt-manager.ts b/lib/test-attempt-manager.ts index 6c428c2b0..5834eeb8f 100644 --- a/lib/test-attempt-manager.ts +++ b/lib/test-attempt-manager.ts @@ -48,6 +48,16 @@ export class TestAttemptManager { return Math.max(data.statuses.length - 1, 0); } + replaceAttempts(testResult: TestSpec, statuses: TestStatus[]): void { + const hash = this._getHash(testResult); + + if (statuses.length) { + this._attempts.set(hash, {statuses: [...statuses]}); + } else { + this._attempts.delete(hash); + } + } + snapshot(testSpecs: Iterable): TestAttemptManagerSnapshot { const snapshot: TestAttemptManagerSnapshot = new Map(); diff --git a/lib/tests-tree-builder/tree-patch.ts b/lib/tests-tree-builder/tree-patch.ts index a76643599..1c58d6643 100644 --- a/lib/tests-tree-builder/tree-patch.ts +++ b/lib/tests-tree-builder/tree-patch.ts @@ -7,11 +7,6 @@ export interface TreeCollectionPatch { } export interface TreePatch { - performance?: { - id: number; - serverStartedAt: number; - serverCompletedAt: number; - }; affectedRootIds: string[]; affectedSuiteIds: string[]; suites: TreeCollectionPatch & { diff --git a/test/unit/lib/gui/tool-runner/index.js b/test/unit/lib/gui/tool-runner/index.js index 25fc242a4..69d518bf9 100644 --- a/test/unit/lib/gui/tool-runner/index.js +++ b/test/unit/lib/gui/tool-runner/index.js @@ -458,7 +458,7 @@ describe('lib/gui/tool-runner/index', () => { const onUpdated = sandbox.stub(); await gui.initialize(); - await gui.refreshTestsIfChanged([changedFile], [], onChanged, onUpdated, 1); + await gui.refreshTestsIfChanged([changedFile], [], onChanged, onUpdated); assert.callCount(toolAdapter.readTests, 2); assert.calledOnceWith(onChanged, true); @@ -490,7 +490,7 @@ describe('lib/gui/tool-runner/index', () => { const onUpdated = sandbox.stub(); await gui.initialize(); - await gui.refreshTestsIfChanged([focusedFile], [], onChanged, onUpdated, 1); + await gui.refreshTestsIfChanged([focusedFile], [], onChanged, onUpdated); assert.callCount(toolAdapter.readTests, 3); assert.deepEqual(toolAdapter.readTests.secondCall.args[0], [focusedFile]); @@ -521,7 +521,7 @@ describe('lib/gui/tool-runner/index', () => { const onUpdated = sandbox.stub(); await gui.initialize(); - await gui.refreshTestsIfChanged([focusedFile], [], onChanged, onUpdated, 1); + await gui.refreshTestsIfChanged([focusedFile], [], onChanged, onUpdated); assert.callCount(toolAdapter.readTests, 2); assert.deepEqual(toolAdapter.readTests.secondCall.args[0], ['tests/**/*.ts']); @@ -557,7 +557,7 @@ describe('lib/gui/tool-runner/index', () => { const onUpdated = sandbox.stub(); await gui.initialize(); - await gui.refreshTestsIfChanged([focusedFile], [], onChanged, onUpdated, 1); + await gui.refreshTestsIfChanged([focusedFile], [], onChanged, onUpdated); assert.callCount(toolAdapter.readTests, 3); assert.calledOnceWith(onChanged, true); @@ -588,7 +588,7 @@ describe('lib/gui/tool-runner/index', () => { const onUpdated = sandbox.stub(); await gui.initialize(); - await gui.refreshTestsIfChanged([changedFile], [], onChanged, onUpdated, 1); + await gui.refreshTestsIfChanged([changedFile], [], onChanged, onUpdated); assert.callCount(toolAdapter.readTests, 2); assert.calledOnceWith(onChanged, true); @@ -607,7 +607,7 @@ describe('lib/gui/tool-runner/index', () => { const gui = initGuiReporter({toolAdapter}); await gui.initialize(); - await gui.refreshTestsIfChanged([changedFile], [], sandbox.stub(), sandbox.stub(), 1); + await gui.refreshTestsIfChanged([changedFile], [], sandbox.stub(), sandbox.stub()); await gui.run([{testName: 'same test', browserName: 'yabro'}]); assert.callCount(toolAdapter.readTests, 3); @@ -633,13 +633,13 @@ describe('lib/gui/tool-runner/index', () => { const onChanged = sandbox.stub(); await gui.initialize(); - await gui.refreshTestsIfChanged([changedFile], [], onChanged, sandbox.stub(), 1); + await gui.refreshTestsIfChanged([changedFile], [], onChanged, sandbox.stub()); assert.calledOnceWith(onChanged, true); }); }); - it('should keep current idle state after removing pending from a test with skipped history', async () => { + it('should not add idle attempt after restoring history for active test', async () => { const changedFile = '/ref/cwd/changed.hermione.ts'; const skippedTest = mkTestAdapter_(stubTest_({file: changedFile, browserId: 'yabro', pending: true})); const activeTest = mkTestAdapter_(stubTest_({file: changedFile, browserId: 'yabro', pending: false})); @@ -653,16 +653,21 @@ describe('lib/gui/tool-runner/index', () => { toolAdapter.readTests.onSecondCall().resolves({tests: [activeTest]}); sandbox.stub(fs, 'pathExists').withArgs(changedFile).resolves(true); sandbox.stub(reportBuilder, 'testsTree').get(() => tree); - reportBuilder.restoreTestHistory.returns(true); const gui = initGuiReporter({toolAdapter}); await gui.initialize(); reportBuilder.addTestResult.resetHistory(); - await gui.refreshTestsIfChanged([changedFile], [], sandbox.stub(), sandbox.stub(), 1); + await gui.refreshTestsIfChanged([changedFile], [], sandbox.stub(), sandbox.stub()); - assert.calledTwice(reportBuilder.addTestResult); + assert.calledOnce(reportBuilder.addTestResult); assert.equal(reportBuilder.addTestResult.firstCall.args[0].status, IDLE); - assert.equal(reportBuilder.addTestResult.secondCall.args[0].status, IDLE); + assert.calledOnceWith(reportBuilder.restoreTestHistory, [{ + suitePath: activeTest.titlePath, + browserId: activeTest.browserId + }], {excludeSkipped: [{ + suitePath: activeTest.titlePath, + browserId: activeTest.browserId + }]}); }); it('should reject a duplicate full name introduced by a partial read', async () => { @@ -679,7 +684,7 @@ describe('lib/gui/tool-runner/index', () => { await gui.initialize(); await assert.isRejected( - gui.refreshTestsIfChanged([changedFile], [], sandbox.stub(), sandbox.stub(), 1), + gui.refreshTestsIfChanged([changedFile], [], sandbox.stub(), sandbox.stub()), /Tests with the same title 'duplicate'/ ); assert.notCalled(reportBuilder.removeTestsByFiles); @@ -707,7 +712,7 @@ describe('lib/gui/tool-runner/index', () => { await gui.initialize(); await assert.isRejected( - gui.refreshTestsIfChanged([changedFile], [], sandbox.stub(), sandbox.stub(), 1), + gui.refreshTestsIfChanged([changedFile], [], sandbox.stub(), sandbox.stub()), /history failed/ ); assert.calledOnceWith(reportBuilder.restoreTestsState, snapshot); diff --git a/test/unit/lib/report-builder/gui.js b/test/unit/lib/report-builder/gui.js index 2d9170f17..f74b196b7 100644 --- a/test/unit/lib/report-builder/gui.js +++ b/test/unit/lib/report-builder/gui.js @@ -7,6 +7,7 @@ const serverUtils = require('lib/server-utils'); const {TestplaneTestResultAdapter} = require('lib/adapters/test-result/testplane'); const {SqliteClient} = require('lib/sqlite-client'); const {GuiTestsTreeBuilder} = require('lib/tests-tree-builder/gui'); +const {StaticTestsTreeBuilder} = require('lib/tests-tree-builder/static'); const {HtmlReporter} = require('lib/plugin-api'); const {FAIL, UPDATED} = require('lib/constants/test-statuses'); const {LOCAL_DATABASE_NAME} = require('lib/constants/database'); @@ -201,6 +202,42 @@ describe('GuiReportBuilder', () => { }); }); + describe('"restoreTestHistory" method', () => { + const mkRow_ = status => [ + '["suite","test"]', 'test', 'chrome', '', '{}', '[]', null, null, null, '[]', 0, 0, + status, 1, 0, '[]' + ]; + const test = {suitePath: ['suite', 'test'], browserId: 'chrome'}; + + it('should exclude skipped history for an active test', async () => { + const reportBuilder = await mkGuiReportBuilder_(); + const successRow = mkRow_(SUCCESS); + const skippedRow = mkRow_(SKIPPED); + const restoredTree = {browsers: {allIds: []}, results: {byId: {}}}; + const build = sandbox.stub().returns({tree: restoredTree}); + sandbox.stub(dbClient, 'getSuitesByTests').returns([successRow, skippedRow]); + sandbox.stub(StaticTestsTreeBuilder, 'create').returns({build}); + sandbox.stub(reportBuilder, 'reuseTestsTree'); + + const restored = reportBuilder.restoreTestHistory([test], {excludeSkipped: [test]}); + + assert.isTrue(restored); + assert.calledOnceWith(build, [successRow]); + assert.calledOnceWith(reportBuilder.reuseTestsTree, restoredTree, {replaceCurrentResults: true}); + }); + + it('should keep current idle result when an active test has only skipped history', async () => { + const reportBuilder = await mkGuiReportBuilder_(); + sandbox.stub(dbClient, 'getSuitesByTests').returns([mkRow_(SKIPPED)]); + sandbox.stub(reportBuilder, 'reuseTestsTree'); + + const restored = reportBuilder.restoreTestHistory([test], {excludeSkipped: [test]}); + + assert.isFalse(restored); + assert.notCalled(reportBuilder.reuseTestsTree); + }); + }); + [ { method: 'reuseTestsTree', diff --git a/test/unit/lib/test-attempt-manager.js b/test/unit/lib/test-attempt-manager.js index fbff7106f..c5a2d3734 100644 --- a/test/unit/lib/test-attempt-manager.js +++ b/test/unit/lib/test-attempt-manager.js @@ -4,6 +4,28 @@ const {TestAttemptManager} = require('lib/test-attempt-manager'); const {FAIL, SUCCESS} = require('lib/constants'); describe('TestAttemptManager', () => { + it('should replace all attempts for a test', () => { + const manager = new TestAttemptManager(); + const test = {fullName: 'test', browserId: 'chrome'}; + manager.registerAttempt(test, SUCCESS); + manager.registerAttempt(test, FAIL); + + manager.replaceAttempts(test, [SUCCESS]); + + assert.equal(manager.getCurrentAttempt(test), 0); + assert.equal(manager.registerAttempt(test, FAIL), 1); + }); + + it('should remove attempts for a test when replacing with an empty list', () => { + const manager = new TestAttemptManager(); + const test = {fullName: 'test', browserId: 'chrome'}; + manager.registerAttempt(test, SUCCESS); + + manager.replaceAttempts(test, []); + + assert.equal(manager.registerAttempt(test, FAIL), 0); + }); + it('should restore attempts only for snapshotted tests', () => { const manager = new TestAttemptManager(); const existing = {fullName: 'existing test', browserId: 'chrome'};