diff --git a/lib/gui/api/index.ts b/lib/gui/api/index.ts index 08c6decec..9d02d55bf 100644 --- a/lib/gui/api/index.ts +++ b/lib/gui/api/index.ts @@ -5,6 +5,17 @@ export interface ServerReadyData { url: string; } +export interface ServerStartData extends ServerReadyData { + ready: Promise; +} + +export interface InitializationProgress { + phase: string; + duration: number; +} + +export type InitializationProgressHandler = (progress: InitializationProgress) => void | Promise; + export class GuiApi { private _gui: ApiFacade; @@ -20,6 +31,14 @@ export class GuiApi { await this._gui.emitAsync(this._gui.events.SERVER_INIT, server); } + async serverListening(data: ServerReadyData): Promise { + await this._gui.emitAsync(this._gui.events.SERVER_LISTENING, data); + } + + async initializationProgress(data: InitializationProgress): Promise { + await this._gui.emitAsync(this._gui.events.INITIALIZATION_PROGRESS, data); + } + async serverReady(data: ServerReadyData): Promise { await this._gui.emitAsync(this._gui.events.SERVER_READY, data); } diff --git a/lib/gui/app.ts b/lib/gui/app.ts index 2c7ad5c8c..2d41bb32d 100644 --- a/lib/gui/app.ts +++ b/lib/gui/app.ts @@ -4,6 +4,7 @@ import {RunParams, ToolRunner, ToolRunnerTree, UndoAcceptImagesResult} from './t import {TestBranch, TestEqualDiffsData, TestRefUpdateData} from '../tests-tree-builder/gui'; import type {ServerArgs} from './index'; +import type {InitializationProgressHandler} from './api'; import type {TestSpec} from '../adapters/tool/types'; export class App { @@ -21,8 +22,8 @@ export class App { return this._toolRunner.tree; } - async initialize(): Promise { - return await this._toolRunner.initialize(); + async initialize(onProgress?: InitializationProgressHandler): Promise { + return await this._toolRunner.initialize(onProgress); } async finalize(): Promise { diff --git a/lib/gui/constants/gui-events.ts b/lib/gui/constants/gui-events.ts index 826f1648b..6309a3b0f 100644 --- a/lib/gui/constants/gui-events.ts +++ b/lib/gui/constants/gui-events.ts @@ -2,6 +2,8 @@ import type {ValueOf} from 'type-fest'; export const GuiEvents = { SERVER_INIT: 'serverInit', + SERVER_LISTENING: 'serverListening', + INITIALIZATION_PROGRESS: 'initializationProgress', SERVER_READY: 'serverReady' } as const; diff --git a/lib/gui/index.ts b/lib/gui/index.ts index 548c6716e..649ea959f 100644 --- a/lib/gui/index.ts +++ b/lib/gui/index.ts @@ -26,10 +26,16 @@ export interface ServerArgs { export default (args: ServerArgs): void => { server.start(args) - .then(async ({url}: { url: string }) => { + .then(async ({url, ready}) => { if (args.cli.options.open) { await openBrowser(url); } + + try { + await ready; + } catch (err: unknown) { + logError(err as Error); + } }) .catch((err: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any logError(err); diff --git a/lib/gui/server.ts b/lib/gui/server.ts index d9f393b2c..19481aec4 100644 --- a/lib/gui/server.ts +++ b/lib/gui/server.ts @@ -6,15 +6,16 @@ import {INTERNAL_SERVER_ERROR, OK} from 'http-codes'; import type {Config} from 'testplane'; import {listenWithFallback} from './listen-with-fallback'; -import {App} from './app'; +import type {App} from './app'; import {ClientEvents, MAX_REQUEST_SIZE} from './constants'; import {logger} from '../common-utils'; import {initPluginsRoutes} from './routes/plugins'; import {BrowserFeature, Feature, ToolName} from '../constants'; -import {getTimeTravelModeEnumSafe} from '../server-utils'; +import {getTimeTravelModeEnumSafe, getConfigForStaticFile} from '../server-utils'; +import {GuiTreeCache} from './tree-cache'; import {NEW_ISSUE_LINK} from '../constants'; import type {ServerArgs} from './index'; -import type {ServerReadyData} from './api'; +import type {ServerStartData} from './api'; import type {TestplaneToolAdapter} from '../adapters/tool/testplane'; import type {ToolRunnerTree} from './tool-runner'; import type {TestplaneConfigAdapter} from '../adapters/config/testplane'; @@ -32,9 +33,9 @@ type TimeTravelConfig = Config['timeTravel']; const originalBrowserConfigs = new Map(); -export type GetInitResponse = (ToolRunnerTree & {customGuiError?: CustomGuiError} & { browserFeatures: Record, features: Feature[]}) | null; +export type GetInitResponse = (ToolRunnerTree & {customGuiError?: CustomGuiError; isCached?: boolean} & { browserFeatures: Record, features: Feature[]}) | null; -export const start = async (args: ServerArgs): Promise => { +export const start = async (args: ServerArgs): Promise => { const {toolAdapter} = args; const {reporterConfig, guiApi} = toolAdapter; @@ -42,8 +43,23 @@ export const start = async (args: ServerArgs): Promise => { throw new Error('Gui API must be initialized before starting gui server'); } - const app = App.create(args); const server = express(); + const treeCache = new GuiTreeCache(args); + let app!: App; + const state: { + appCreated?: Promise; + initialization?: Promise; + cachedTree?: ReturnType; + initialized?: boolean; + } = {}; + + const getApp = async (): Promise => { + if (!state.appCreated) { + throw new Error('GUI initialization has not started'); + } + + return state.appCreated; + }; server.use(bodyParser.json({limit: MAX_REQUEST_SIZE})); @@ -69,18 +85,47 @@ export const start = async (args: ServerArgs): Promise => { } }); - server.get('/events', (_req, res) => { - res.writeHead(OK, {'Content-Type': 'text/event-stream'}); + server.get('/events', async (_req, res) => { + try { + const app = await getApp(); - app.addClient(res); + res.writeHead(OK, {'Content-Type': 'text/event-stream'}); + app.addClient(res); + } catch (e) { + res.status(INTERNAL_SERVER_ERROR).json({error: {message: (e as Error).message}}); + } }); server.set('json replacer', (_key: string, val: unknown) => { return typeof val === 'function' ? val.toString() : val; }); - server.get('/init', async (_req, res) => { + server.get('/init', async (req, res) => { try { + if (!state.initialization) { + throw new Error('GUI initialization has not started'); + } + + if (req.query.cached === '1' && !args.cli.options.autoRun) { + const cachedTree = await state.cachedTree; + if (cachedTree && !state.initialized) { + res.json({ + ...cachedTree, + config: {...getConfigForStaticFile(reporterConfig), customGui: {}}, + apiValues: toolAdapter.htmlReporter.values, + autoRun: false, + features: [], + browserFeatures: {}, + isCached: true + } satisfies GetInitResponse); + return; + } + } + + await state.initialization; + + const app = await getApp(); + if (toolAdapter.toolName === ToolName.Testplane) { await (toolAdapter as TestplaneToolAdapter).initGuiHandler(); } @@ -88,8 +133,13 @@ export const start = async (args: ServerArgs): Promise => { res.json(app.data satisfies GetInitResponse); } catch (e: unknown) { const error = e as Error; - if (!app.data) { - throw new Error(`Failed to initialize custom GUI ${error.message}`); + if (!app?.data) { + res.status(INTERNAL_SERVER_ERROR).json({ + error: { + message: `Failed to initialize GUI: ${error.message}` + } + }); + return; } res.json({ ...app.data, @@ -103,6 +153,23 @@ export const start = async (args: ServerArgs): Promise => { } }); + server.use(async (_req, res, next) => { + try { + if (!state.initialized) { + res.status(503).json({error: {message: 'Test discovery is still running. Please wait for the tree to finish updating.'}}); + return; + } + if (!state.initialization) { + throw new Error('GUI initialization has not started'); + } + + await state.initialization; + next(); + } catch (e) { + res.status(INTERNAL_SERVER_ERROR).json({error: {message: (e as Error).message}}); + } + }); + server.post('/update-time-travel-settings', (req, res) => { try { if (toolAdapter.toolName !== ToolName.Testplane) { @@ -270,13 +337,14 @@ export const start = async (args: ServerArgs): Promise => { }); onExit(() => { - app.finalize(); + void state.appCreated?.then(app => app.finalize()).catch(() => undefined); logger.log('server shutting down'); }); server.get('/refresh-tests', async (_req, res) => { try { const tree = await app.refreshTests(); + void treeCache.write(tree); res.json(tree satisfies GetInitResponse); } catch (e: unknown) { res.status(INTERNAL_SERVER_ERROR).send(`Error while refreshing tests: ${(e as Error).message}`); @@ -293,8 +361,6 @@ export const start = async (args: ServerArgs): Promise => { } }); - await app.initialize(); - const {port: requestedPort, hostname} = args.cli.options; const {actualPort, hostnameForUrl} = await listenWithFallback({ @@ -312,8 +378,30 @@ export const start = async (args: ServerArgs): Promise => { } const data = {url: `http://${hostnameForUrl}:${actualPort}`}; + state.cachedTree = treeCache.read(); + state.appCreated = new Promise((resolve, reject) => { + setImmediate(() => { + import('./app') + .then(({App}) => { + app = App.create(args); + resolve(app); + }) + .catch(reject); + }); + }); + state.initialization = state.appCreated.then(async app => { + // Read the snapshot before loading test files synchronously so it is available to the first request. + await state.cachedTree; + await app.initialize(progress => guiApi.initializationProgress(progress)); + state.initialized = true; + state.cachedTree = undefined; + await treeCache.write(app.data); + }); + state.initialization.catch(() => undefined); + + await guiApi.serverListening(data); - await guiApi.serverReady(data); + const ready = state.initialization.then(() => guiApi.serverReady(data)); - return data; + return {...data, ready}; }; diff --git a/lib/gui/tool-runner/index.ts b/lib/gui/tool-runner/index.ts index 315ae82e7..28108574c 100644 --- a/lib/gui/tool-runner/index.ts +++ b/lib/gui/tool-runner/index.ts @@ -46,6 +46,15 @@ import type { import type {TestAdapter} from '../../adapters/test/index'; import type {TestCollectionAdapter} from '../../adapters/test-collection'; import type {ConfigAdapter} from '../../adapters/config'; +import type {InitializationProgressHandler} from '../api'; + +export const InitializationPhases = { + PREPARE_DATABASE: 'prepare-database', + CREATE_REPORT_BUILDER: 'create-report-builder', + READ_TESTS: 'read-tests', + SAVE_STATIC_FILES: 'save-static-files', + BUILD_TESTS_TREE: 'build-tests-tree' +} as const; export type ToolRunnerTree = GuiReportBuilderResult & Pick & { features: Feature[]; @@ -119,35 +128,57 @@ export class ToolRunner { }); } - async initialize(): Promise { - await mergeDatabasesForReuse(this._reportPath); - await prepareLocalDatabase(this._reportPath); + async initialize(onProgress?: InitializationProgressHandler): Promise { + await this._runInitializationPhase(InitializationPhases.PREPARE_DATABASE, onProgress, async () => { + await mergeDatabasesForReuse(this._reportPath); + await prepareLocalDatabase(this._reportPath); + }); - const dbClient = await SqliteClient.create({htmlReporter: this._toolAdapter.htmlReporter, reportPath: this._reportPath, reuse: true}); - const imageStore = new SqliteImageStore(dbClient); + const dbClient = await this._runInitializationPhase(InitializationPhases.CREATE_REPORT_BUILDER, onProgress, async () => { + const dbClient = await SqliteClient.create({htmlReporter: this._toolAdapter.htmlReporter, reportPath: this._reportPath, reuse: true}); + const imageStore = new SqliteImageStore(dbClient); - const imagesInfoSaver = new ImagesInfoSaver({ - imageFileSaver: this._toolAdapter.htmlReporter.imagesSaver, - expectedPathsCache: this._expectedImagesCache, - imageStore, - reportPath: this._toolAdapter.htmlReporter.config.path - }); + const imagesInfoSaver = new ImagesInfoSaver({ + imageFileSaver: this._toolAdapter.htmlReporter.imagesSaver, + expectedPathsCache: this._expectedImagesCache, + imageStore, + reportPath: this._toolAdapter.htmlReporter.config.path + }); + + this._reportBuilder = GuiReportBuilder.create({ + htmlReporter: this._toolAdapter.htmlReporter, + reporterConfig: this._reporterConfig, + dbClient, + imagesInfoSaver + }); + this._toolAdapter.handleTestResults(this._reportBuilder, this._eventSource); - this._reportBuilder = GuiReportBuilder.create({ - htmlReporter: this._toolAdapter.htmlReporter, - reporterConfig: this._reporterConfig, - dbClient, - imagesInfoSaver + return dbClient; }); - this._toolAdapter.handleTestResults(this._reportBuilder, this._eventSource); - this._collection = await this._readTests(); + this._collection = await this._runInitializationPhase(InitializationPhases.READ_TESTS, onProgress, () => this._readTests()); this._toolAdapter.htmlReporter.emit(PluginEvents.DATABASE_CREATED, dbClient.getRawConnection()); - await this._reportBuilder.saveStaticFiles(); + await this._runInitializationPhase(InitializationPhases.SAVE_STATIC_FILES, onProgress, () => this._ensureReportBuilder().saveStaticFiles()); - this._reportBuilder.setApiValues(this._toolAdapter.htmlReporter.values); - await this._handleRunnableCollection(); + await this._runInitializationPhase(InitializationPhases.BUILD_TESTS_TREE, onProgress, async () => { + this._ensureReportBuilder().setApiValues(this._toolAdapter.htmlReporter.values); + await this._handleRunnableCollection(); + }); + } + + private async _runInitializationPhase( + phase: string, + onProgress: InitializationProgressHandler | undefined, + action: () => T | Promise + ): Promise { + const startedAt = Date.now(); + + try { + return await action(); + } finally { + await onProgress?.({phase, duration: Date.now() - startedAt}); + } } async _readTests(): Promise { diff --git a/lib/gui/tree-cache.ts b/lib/gui/tree-cache.ts new file mode 100644 index 000000000..41691274e --- /dev/null +++ b/lib/gui/tree-cache.ts @@ -0,0 +1,71 @@ +import path from 'node:path'; +import {createHash, randomUUID} from 'node:crypto'; +import {promisify} from 'node:util'; +import {gzip, gunzip} from 'node:zlib'; +import fs from 'fs-extra'; +import {version} from '../../package.json'; +import type {ServerArgs} from './index'; +import type {ToolRunnerTree} from './tool-runner'; +import {logger} from '../common-utils'; + +type Snapshot = Pick; +const compress = promisify(gzip); +const decompress = promisify(gunzip); + +// The cache is only used for previewing tests, never for running them. +export class GuiTreeCache { + private readonly _path: string; + private readonly _key: string; + + constructor({paths, cli, toolAdapter}: ServerArgs) { + this._path = path.resolve(toolAdapter.reporterConfig.path, '.gui-tree-cache.json.gz'); + const {config, grep, tag, set, browser, require: requires} = cli.tool || {}; + this._key = createHash('sha256').update(JSON.stringify({ + version, cwd: process.cwd(), tool: toolAdapter.toolName, + paths, config, grep: grep?.toString(), tag, set, browser, requires, + browserIds: toolAdapter.config.browserIds, + baseHost: toolAdapter.reporterConfig.baseHost + })).digest('hex'); + } + + async read(): Promise { + try { + const {schema, key, snapshot} = JSON.parse((await decompress(await fs.readFile(this._path))).toString()); + if (schema !== 1 || key !== this._key || !Array.isArray(snapshot?.skips)) { + return null; + } + for (const kind of ['suites', 'browsers', 'results', 'images']) { + const nodes = snapshot.tree?.[kind]; + if (!Array.isArray(nodes?.allIds) || !nodes?.byId || + nodes.allIds.some((id: string) => !nodes.byId[id])) { + return null; + } + } + if (!Array.isArray(snapshot.tree.suites.allRootIds)) { + return null; + } + return snapshot; + } catch { + // A missing, corrupt or outdated cache must not prevent normal startup. + return null; + } + } + + async write(data: ToolRunnerTree | null): Promise { + if (!data) { + return; + } + const temporaryPath = `${this._path}.${randomUUID()}.tmp`; + try { + const {tree, skips, timestamp, date} = data; + const snapshot: Snapshot = {tree, skips, timestamp, date}; + const contents = await compress(JSON.stringify({schema: 1, key: this._key, snapshot})); + await fs.outputFile(temporaryPath, contents); + await fs.rename(temporaryPath, this._path); + } catch (error) { + logger.warn(`Could not save GUI tree cache: ${(error as Error).message}`); + } finally { + await fs.remove(temporaryPath).catch(() => undefined); + } + } +} diff --git a/lib/static/modules/action-names.ts b/lib/static/modules/action-names.ts index 48d8f5cc5..e25b7480c 100644 --- a/lib/static/modules/action-names.ts +++ b/lib/static/modules/action-names.ts @@ -5,6 +5,8 @@ export default { FIN_STATIC_REPORT: 'FIN_STATIC_REPORT', RUN_ALL_TESTS: 'RUN_ALL_TESTS', RUN_FAILED_TESTS: 'RUN_FAILED_TESTS', + QUEUE_TEST_RUN: 'QUEUE_TEST_RUN', + CLEAR_QUEUED_TEST_RUN: 'CLEAR_QUEUED_TEST_RUN', SET_REPEAT_COUNT: 'SET_REPEAT_COUNT', SET_REPEAT_LEFT: 'SET_REPEAT_LEFT', SET_RUN_OPTIONS: 'SET_RUN_OPTIONS', diff --git a/lib/static/modules/actions/lifecycle.ts b/lib/static/modules/actions/lifecycle.ts index 552b9058f..6785af337 100644 --- a/lib/static/modules/actions/lifecycle.ts +++ b/lib/static/modules/actions/lifecycle.ts @@ -17,14 +17,15 @@ import {DataForStaticFile} from '@/server-utils'; import {GetInitResponse} from '@/gui/server'; import {Tree} from '@/tests-tree-builder/base'; import {BrowserItem} from '@/types'; -import {createNotificationError} from '@/static/modules/actions/notifications'; +import {createNotification, createNotificationError, dismissNotification} from '@/static/modules/actions/notifications'; import {setRefreshLoading} from '@/static/modules/actions/filters'; 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 {cancelQueuedTestRun, thunkRunQueuedTests} from './run-tests'; -export type InitGuiReportAction = Action; +export type InitGuiReportAction = Action; const initGuiReport = (payload: InitGuiReportAction['payload']): InitGuiReportAction => ({type: actionNames.INIT_GUI_REPORT, payload}); @@ -35,8 +36,18 @@ interface InitGuiReportData { export const thunkInitGuiReport = ({isNewUi}: InitGuiReportData = {}): AppThunk => { return async (dispatch) => { performance?.mark?.(performanceMarks.JS_EXEC); + let showingCache = false; try { - const appState = await axios.get('/init'); + let appState = await axios.get('/init?cached=1'); + + if (appState.data?.isCached) { + dispatch(initGuiReport({...appState.data, db: null, isNewUi})); + showingCache = true; + dispatch(createNotification('gui-tree-cache', 'info', + 'Showing cached tests. Updating the tree; Run will queue tests until initialization finishes.', + {dismissAfter: 0})); + appState = await axios.get('/init'); + } if (!appState.data) { throw new Error('Could not load app data. The report might be broken. Please check your project settings or try deleting results folder and relaunching UI server.'); @@ -56,7 +67,10 @@ export const thunkInitGuiReport = ({isNewUi}: InitGuiReportData = {}): AppThunk performance?.mark?.(performanceMarks.PLUGINS_LOADED); - dispatch(initGuiReport({...appState.data, db, isNewUi})); + dispatch(initGuiReport({...appState.data, db, isNewUi, ...(showingCache ? {preserveUiState: true} : {})})); + if (showingCache) { + dispatch(dismissNotification('gui-tree-cache')); + } if (appState.data.customGuiError) { const {customGuiError} = appState.data; @@ -64,7 +78,20 @@ export const thunkInitGuiReport = ({isNewUi}: InitGuiReportData = {}): AppThunk dispatch(createNotificationError('initGuiReport', {name: 'CustomGuiError', message: customGuiError?.response.data})); delete appState.data.customGuiError; } + if (showingCache) { + await dispatch(thunkRunQueuedTests()); + } } catch (e: unknown) { + if (showingCache) { + dispatch(cancelQueuedTestRun()); + dispatch({type: actionNames.PROCESS_BEGIN}); + dispatch({type: actionNames.SET_AVAILABLE_FEATURES, payload: {features: []}}); + dispatch(dismissNotification('gui-tree-cache')); + } else { + dispatch({type: actionNames.UPDATE_LOADING_TITLE, payload: 'Failed to initialize Testplane UI'}); + dispatch({type: actionNames.UPDATE_LOADING_IS_IN_PROGRESS, payload: false}); + dispatch({type: actionNames.UPDATE_LOADING_VISIBILITY, payload: true}); + } dispatch(createNotificationError('initGuiReport', e as Error)); } }; diff --git a/lib/static/modules/actions/run-tests.ts b/lib/static/modules/actions/run-tests.ts index 4abeb5cbc..cd911244e 100644 --- a/lib/static/modules/actions/run-tests.ts +++ b/lib/static/modules/actions/run-tests.ts @@ -5,10 +5,22 @@ import actionNames from '@/static/modules/action-names'; import {Action, AppThunk} from '@/static/modules/actions/types'; import {TestSpec} from '@/adapters/tool/types'; import {connectToDatabase, getMainDatabaseUrl} from '@/db-utils/client'; -import {createNotificationError} from '@/static/modules/actions/notifications'; +import {createNotification, createNotificationError, dismissNotification} from '@/static/modules/actions/notifications'; import {TestBranch} from '@/tests-tree-builder/gui'; import {TestStatus} from '@/constants'; -import {RunOptions} from '@/static/new-ui/types/store'; +import {RunOptions, State} from '@/static/new-ui/types/store'; + +type QueuedTestRun = NonNullable; +type QueueTestRunAction = Action; +type ClearQueuedTestRunAction = Action; + +export const cancelQueuedTestRun = (): AppThunk => async (dispatch, getState) => { + if (!getState().app.queuedTestRun) { + return; + } + dispatch({type: actionNames.CLEAR_QUEUED_TEST_RUN}); + dispatch(dismissNotification('queued-test-run')); +}; export type RunTestAction = Action; export const runTest = (): RunTestAction => ({type: actionNames.RETRY_TEST}); @@ -16,16 +28,27 @@ export const setRepeatCount = (repeatCount: number): Action => ({type: actionNames.SET_REPEAT_LEFT, payload: {repeatLeft}}); export const setRunOptions = (runOptions: RunOptions): Action => ({type: actionNames.SET_RUN_OPTIONS, payload: {runOptions}}); -export const thunkRunTests = ({tests = []}: {tests?: TestSpec[]} = {}): AppThunk => { +export const thunkRunTests = ({tests = [], repeatCount: requestedRepeatCount}: {tests?: TestSpec[]; repeatCount?: number} = {}): AppThunk => { return async (dispatch, getState) => { - const {repeatCount} = getState(); + const state = getState(); + const repeatCount = requestedRepeatCount ?? state.repeatCount; + + if (state.app?.isGuiInitializing) { + if (!state.app.queuedTestRun) { + dispatch({type: actionNames.QUEUE_TEST_RUN, payload: {tests: tests.map(test => ({...test})), repeatCount}}); + dispatch(createNotification('queued-test-run', 'info', + 'Run queued. Tests will start automatically after initialization. Press Stop to cancel.', + {dismissAfter: 0})); + } + return; + } dispatch(runTest()); try { await axios.post('/run', {tests, repeatCount}); } catch (e) { - // TODO: report error via notifications - console.error('Error while running tests:', e); + dispatch({type: actionNames.CLEAR_QUEUED_TEST_RUN}); + dispatch(createNotificationError('runTests', e as Error)); } }; }; @@ -34,12 +57,26 @@ export type RunAllTestsAction = Action; export const runAllTests = (): RunAllTestsAction => ({type: actionNames.RUN_ALL_TESTS}); export const thunkRunAllTests = (): AppThunk => { - return async (dispatch) => { - dispatch(runAllTests()); + return async (dispatch, getState) => { + if (!getState()?.app?.isGuiInitializing) { + dispatch(runAllTests()); + } await dispatch(thunkRunTests()); }; }; +export const thunkRunQueuedTests = (): AppThunk => async (dispatch, getState) => { + const {queuedTestRun, isGuiInitializing} = getState().app; + if (!queuedTestRun || isGuiInitializing) { + return; + } + dispatch(cancelQueuedTestRun()); + if (!queuedTestRun.tests.length) { + dispatch(runAllTests()); + } + await dispatch(thunkRunTests(queuedTestRun)); +}; + export type RunFailedTestsAction = Action; export const runFailedTests = (): RunFailedTestsAction => ({type: actionNames.RUN_FAILED_TESTS}); @@ -71,7 +108,11 @@ export type StopTestsAction = Action; export const stopTests = (): StopTestsAction => ({type: actionNames.STOP_TESTS}); export const thunkStopTests = (): AppThunk => { - return async (dispatch) => { + return async (dispatch, getState) => { + if (getState().app?.queuedTestRun) { + await dispatch(cancelQueuedTestRun()); + return; + } try { await axios.post('/stop'); dispatch(stopTests()); @@ -113,6 +154,8 @@ export type TestResultAction = Action ({type: actionNames.TEST_RESULT, payload}); export type RunTestsAction = + | QueueTestRunAction + | ClearQueuedTestRunAction | RunAllTestsAction | RunFailedTestsAction | RunSuiteAction diff --git a/lib/static/modules/default-state.ts b/lib/static/modules/default-state.ts index 07d634569..c082b81e5 100644 --- a/lib/static/modules/default-state.ts +++ b/lib/static/modules/default-state.ts @@ -99,6 +99,8 @@ export default Object.assign({config: configDefaults}, { app: { isNewUi: false, isInitialized: false, + isGuiInitializing: false, + queuedTestRun: null, availableFeatures: [], viewMode: ViewMode.ALL, diff --git a/lib/static/modules/reducers/filters.ts b/lib/static/modules/reducers/filters.ts index 6f8cbcb69..0ff7f97b5 100644 --- a/lib/static/modules/reducers/filters.ts +++ b/lib/static/modules/reducers/filters.ts @@ -26,6 +26,9 @@ export default (state: State, action: FiltersAction | InitGuiReportAction | Init switch (action.type) { case actionNames.INIT_GUI_REPORT: case actionNames.INIT_STATIC_REPORT: { + if ('preserveUiState' in action.payload && action.payload.preserveUiState) { + return applyStateUpdate(state, {app: {isRefreshTestsLoading: false}}); + } const viewMode = localStorageWrapper.getItem('app.viewMode', ViewMode.ALL) as ViewMode; const visualChecksPageDiffMode = localStorageWrapper.getItem(VISUAL_CHECKS_PAGE_DIFF_MODE_KEY, DiffModes.TWO_UP_INTERACTIVE.id) as DiffModeId; diff --git a/lib/static/modules/reducers/gui.js b/lib/static/modules/reducers/gui.js index 229e9d5e9..b66da85ba 100644 --- a/lib/static/modules/reducers/gui.js +++ b/lib/static/modules/reducers/gui.js @@ -5,7 +5,23 @@ import {EditScreensFeature, RunTestsFeature} from '@/constants'; export default (state, action) => { switch (action.type) { case actionNames.INIT_GUI_REPORT: { - return applyStateUpdate(state, {gui: true, app: {availableFeatures: [RunTestsFeature, EditScreensFeature]}}); + const isCached = Boolean(action.payload.isCached); + return applyStateUpdate(state, { + gui: true, + processing: Boolean(state.running), + app: { + isGuiInitializing: isCached, + availableFeatures: isCached ? [RunTestsFeature] : [RunTestsFeature, EditScreensFeature] + } + }); + } + + case actionNames.QUEUE_TEST_RUN: { + return applyStateUpdate(state, {running: true, processing: true, app: {queuedTestRun: action.payload}}); + } + + case actionNames.CLEAR_QUEUED_TEST_RUN: { + return applyStateUpdate(state, {running: false, processing: false, stopping: false, app: {queuedTestRun: null}}); } case actionNames.INIT_STATIC_REPORT: { diff --git a/lib/static/modules/reducers/index.js b/lib/static/modules/reducers/index.js index 26f423707..76b85ff4c 100644 --- a/lib/static/modules/reducers/index.js +++ b/lib/static/modules/reducers/index.js @@ -86,8 +86,8 @@ const reducer = reduceReducers( ); export default (state, action) => { - // Ignore static accepter editions in "processing" state - if (state?.processing && staticAccepterEditingActions.has(action.type)) { + // Ignore screenshot edits while processing or previewing the cached tree. + if ((state?.processing || state?.app?.isGuiInitializing) && staticAccepterEditingActions.has(action.type)) { return state; } 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..a6c244452 100644 --- a/lib/static/modules/reducers/new-ui-grouped-tests/index.ts +++ b/lib/static/modules/reducers/new-ui-grouped-tests/index.ts @@ -42,15 +42,24 @@ export default (state: State, action: SomeAction): State => { sectionId: 'error' } satisfies GroupByErrorExpression); - return applyStateUpdate(state, { + const preserveUiState = 'preserveUiState' in action.payload && action.payload.preserveUiState; + const selectedExpressions = preserveUiState + ? state.app.groupTestsData.currentExpressionIds.flatMap(id => availableExpressions.filter(expr => expr.id === id)) + : []; + const nextState = applyStateUpdate(state, { app: { groupTestsData: { availableExpressions, - currentExpressionIds: [], + currentExpressionIds: selectedExpressions.map(expr => expr.id), availableSections } } }); + if (selectedExpressions.length) { + const byId = groupTests(selectedExpressions, nextState.tree.results.byId, nextState.tree.images.byId, nextState.config.errorPatterns); + return {...nextState, tree: {...nextState.tree, groups: {byId, allRootIds: Object.keys(byId)}}}; + } + return nextState; } case actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION: { diff --git a/lib/static/modules/reducers/sort-tests.ts b/lib/static/modules/reducers/sort-tests.ts index d5277a642..100a00068 100644 --- a/lib/static/modules/reducers/sort-tests.ts +++ b/lib/static/modules/reducers/sort-tests.ts @@ -20,6 +20,9 @@ export default (state: State, action: SomeAction): State => { switch (action.type) { case actionNames.INIT_STATIC_REPORT: case actionNames.INIT_GUI_REPORT: { + if ('preserveUiState' in action.payload && action.payload.preserveUiState) { + return state; + } const availableExpressions = DEFAULT_AVAILABLE_EXPRESSIONS; return applyStateUpdate(state, { diff --git a/lib/static/modules/reducers/suites-page.ts b/lib/static/modules/reducers/suites-page.ts index 24b535071..2104334d5 100644 --- a/lib/static/modules/reducers/suites-page.ts +++ b/lib/static/modules/reducers/suites-page.ts @@ -32,12 +32,28 @@ export default (state: State, action: SomeAction): State => { const expandedTreeNodesById: Record = Object.assign({}, state.ui.suitesPage.expandedTreeNodesById); for (const nodeId of allTreeNodeIds) { - expandedTreeNodesById[nodeId] = true; + if (action.type !== actionNames.INIT_GUI_REPORT || !action.payload.preserveUiState || + expandedTreeNodesById[nodeId] === undefined) { + expandedTreeNodesById[nodeId] = true; + } } let currentGroupId: string | null | undefined = null; let currentTreeNodeId: string | null | undefined = state.app[Page.suitesPage].currentTreeNodeId; let treeViewMode = state.ui.suitesPage.treeViewMode; + if (action.type === actionNames.INIT_GUI_REPORT && action.payload.preserveUiState) { + const {currentBrowserId, currentGroupId} = state.app.suitesPage; + const browserExists = currentBrowserId && state.tree.browsers.byId[currentBrowserId]; + return applyStateUpdate(state, { + app: {suitesPage: { + currentBrowserId: browserExists ? currentBrowserId : null, + currentGroupId: currentGroupId && state.tree.groups.byId[currentGroupId] ? currentGroupId : null, + currentTreeNodeId: currentTreeNodeId && allTreeNodeIds.includes(currentTreeNodeId) ? currentTreeNodeId : null, + currentStepId: null + }}, + ui: {suitesPage: {expandedTreeNodesById}} + }); + } if (action.type === actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION || action.type === actionNames.SUITES_PAGE_SET_TREE_VIEW_MODE) { const {currentBrowserId} = state.app.suitesPage; if (currentBrowserId) { diff --git a/lib/static/modules/reducers/tree/index.js b/lib/static/modules/reducers/tree/index.js index 3326507ce..7dfc87ddf 100644 --- a/lib/static/modules/reducers/tree/index.js +++ b/lib/static/modules/reducers/tree/index.js @@ -46,6 +46,23 @@ export default ((state, action) => { updateAllSuitesStatus(tree, filteredBrowsers); initNodesStates({tree, view: state.view, app: state.app}); + if (action.payload.preserveUiState) { + // Preserve only user selections; derive statuses and visibility from the fresh tree. + for (const kind of ['suites', 'browsers', 'results', 'images']) { + for (const id of tree[kind].allIds) { + const previous = state.tree[kind].stateById[id]; + if (!previous) { + continue; + } + for (const key of ['shouldBeOpened', 'checkStatus']) { + if (previous[key] !== undefined) { + tree[kind].stateById[id][key] = previous[key]; + } + } + } + } + updateParentsChecked(tree, [...new Set(tree.browsers.allIds.map(id => tree.browsers.byId[id].parentId))]); + } resolveUpdatedStatuses(tree.results.byId, tree.images.byId, tree.suites.byId); if (staticImageAccepter.checkIsEnabled(state.config?.staticImageAccepter, state.gui)) { diff --git a/lib/static/modules/reducers/view.js b/lib/static/modules/reducers/view.js index 51a5916bf..e8fd2f485 100644 --- a/lib/static/modules/reducers/view.js +++ b/lib/static/modules/reducers/view.js @@ -8,6 +8,9 @@ export default (state, action) => { switch (action.type) { case actionNames.INIT_GUI_REPORT: case actionNames.INIT_STATIC_REPORT: { + if (action.payload.preserveUiState) { + return state; + } const {baseHost, defaultView: viewMode, diffMode} = state.config; const viewQuery = getViewQuery(window.location.search); const lsView = localStorageWrapper.getItem('view', {}); diff --git a/lib/static/new-ui/app/App.tsx b/lib/static/new-ui/app/App.tsx index 67d03a8cd..0fc88635b 100644 --- a/lib/static/new-ui/app/App.tsx +++ b/lib/static/new-ui/app/App.tsx @@ -11,6 +11,7 @@ import {HashRouter, Navigate, Route, Routes} from 'react-router-dom'; import {LoadingBar} from '@/static/new-ui/components/LoadingBar'; import {GuiniToolbarOverlay} from '@/static/new-ui/components/GuiniToolbarOverlay'; import {AutoRun} from '@/static/new-ui/components/AutoRun'; +import {QueuedTestRunNotification} from '@/static/new-ui/components/QueuedTestRunNotification'; import {MainLayout} from '../components/MainLayout'; import {SuitesPage} from '../features/suites/components/SuitesPage'; import {VisualChecksPage} from '../features/visual-checks/components/VisualChecksPage'; @@ -56,6 +57,7 @@ export function App(): ReactNode { + }> diff --git a/lib/static/new-ui/app/gui.tsx b/lib/static/new-ui/app/gui.tsx index fa549541f..b38f21498 100644 --- a/lib/static/new-ui/app/gui.tsx +++ b/lib/static/new-ui/app/gui.tsx @@ -28,7 +28,9 @@ function Gui(): ReactNode { } eventSource.addEventListener(ClientEvents.CONNECTED, (): void => { - store.dispatch({type: actionNames.UPDATE_LOADING_VISIBILITY, payload: false}); + if (store.getState().app.isInitialized) { + store.dispatch({type: actionNames.UPDATE_LOADING_VISIBILITY, payload: false}); + } store.dispatch(setGuiServerConnectionStatus({isConnected: true})); }); diff --git a/lib/static/new-ui/components/QueuedTestRunNotification/index.tsx b/lib/static/new-ui/components/QueuedTestRunNotification/index.tsx new file mode 100644 index 000000000..e13124669 --- /dev/null +++ b/lib/static/new-ui/components/QueuedTestRunNotification/index.tsx @@ -0,0 +1,29 @@ +import {useEffect} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; +import {useToaster} from '@gravity-ui/uikit'; +import {cancelQueuedTestRun} from '@/static/modules/actions/run-tests'; +import type {State} from '@/static/new-ui/types/store'; + +export function QueuedTestRunNotification(): null { + const queuedTestRun = useSelector((state: State) => state.app.queuedTestRun); + const dispatch = useDispatch(); + const toaster = useToaster(); + + useEffect(() => { + if (!queuedTestRun) { + return; + } + const name = 'queued-test-run'; + toaster.add({ + name, + title: 'Run queued', + content: 'Tests will start automatically after initialization.', + autoHiding: false, + isClosable: false, + actions: [{label: 'Cancel', onClick: () => dispatch(cancelQueuedTestRun())}] + }); + return () => toaster.remove(name); + }, [queuedTestRun, dispatch, toaster]); + + return null; +} diff --git a/lib/static/new-ui/types/store.ts b/lib/static/new-ui/types/store.ts index d5db30c66..46ec2c40b 100644 --- a/lib/static/new-ui/types/store.ts +++ b/lib/static/new-ui/types/store.ts @@ -17,6 +17,7 @@ import {EntityType} from '@/static/new-ui/features/suites/components/SuitesPage/ import {DbDetails} from '@/db-utils/common'; import {Stats, PerBrowserStats} from '@/tests-tree-builder/static'; import type {Database} from '@gemini-testing/sql.js'; +import type {TestSpec} from '@/adapters/tool/types'; export interface GroupEntity { id: string; @@ -262,6 +263,8 @@ export interface State { app: { isNewUi: boolean; isInitialized: boolean; + isGuiInitializing: boolean; + queuedTestRun: {tests: TestSpec[]; repeatCount: number} | null; availableFeatures: Feature[], isSearchLoading?: boolean; isRefreshTestsLoading?: boolean; diff --git a/test/unit/lib/gui/api/index.js b/test/unit/lib/gui/api/index.js index 252e7f180..e8b2045fa 100644 --- a/test/unit/lib/gui/api/index.js +++ b/test/unit/lib/gui/api/index.js @@ -42,4 +42,29 @@ describe('lib/gui/api', () => { assert.calledOnceWith(onServerReady, {url: 'http://my.server'}); }); }); + + describe('serverListening', () => { + it('should emit "SERVER_LISTENING" event through gui api', () => { + const api = GuiApi.create(); + const onServerListening = sinon.spy().named('onServerListening'); + api.gui.on(GuiEvents.SERVER_LISTENING, onServerListening); + + api.serverListening({url: 'http://my.server'}); + + assert.calledOnceWith(onServerListening, {url: 'http://my.server'}); + }); + }); + + describe('initializationProgress', () => { + it('should emit "INITIALIZATION_PROGRESS" event through gui api', () => { + const api = GuiApi.create(); + const onInitializationProgress = sinon.spy().named('onInitializationProgress'); + api.gui.on(GuiEvents.INITIALIZATION_PROGRESS, onInitializationProgress); + const progress = {phase: 'read-tests', duration: 100}; + + api.initializationProgress(progress); + + assert.calledOnceWith(onInitializationProgress, progress); + }); + }); }); diff --git a/test/unit/lib/gui/server.js b/test/unit/lib/gui/server.js index 6bbec02b8..17fc4f71b 100644 --- a/test/unit/lib/gui/server.js +++ b/test/unit/lib/gui/server.js @@ -3,6 +3,7 @@ const _ = require('lodash'); const proxyquire = require('proxyquire'); const {App} = require('lib/gui/app'); +const {GuiTreeCache} = require('lib/gui/tree-cache'); const {stubToolAdapter} = require('../../utils'); describe('lib/gui/server', () => { @@ -13,6 +14,8 @@ describe('lib/gui/server', () => { let staticMiddleware; let initPluginRoutesStub; let RouterStub; + let onExitCallback; + let appData; const mkExpressApp_ = () => ({ use: sandbox.stub(), @@ -35,8 +38,19 @@ describe('lib/gui/server', () => { return server.start(opts); }; + const startServerAndWait = async (opts = {}) => { + const result = await startServer(opts); + await result.ready; + + return result; + }; + beforeEach(() => { + sandbox.stub(GuiTreeCache.prototype, 'read').resolves(null); + sandbox.stub(GuiTreeCache.prototype, 'write').resolves(); sandbox.stub(App, 'create').returns(Object.create(App.prototype)); + appData = null; + sandbox.stub(App.prototype, 'data').get(() => appData); sandbox.stub(App.prototype, 'initialize').resolves(); sandbox.stub(App.prototype, 'findEqualDiffs').resolves(); sandbox.stub(App.prototype, 'finalize'); @@ -46,6 +60,7 @@ describe('lib/gui/server', () => { RouterStub = sandbox.stub(); bodyParserStub = {json: sandbox.stub()}; initPluginRoutesStub = sandbox.stub(); + onExitCallback = undefined; server = proxyquire('lib/gui/server', { express: Object.assign(() => expressStub, { @@ -53,7 +68,9 @@ describe('lib/gui/server', () => { Router: () => RouterStub }), 'body-parser': bodyParserStub, - 'signal-exit': {onExit: sandbox.stub().yields()}, + 'signal-exit': {onExit: sandbox.stub().callsFake(callback => { + onExitCallback = callback; + })}, '../common-utils': {logger: {log: sandbox.stub()}}, './routes/plugins': {initPluginsRoutes: initPluginRoutesStub} }); @@ -67,9 +84,10 @@ describe('lib/gui/server', () => { const toolAdapter = stubToolAdapter(); const {guiApi} = toolAdapter; - await startServer({toolAdapter}); + await startServerAndWait({toolAdapter}); assert.calledOnceWith(guiApi.initServer, expressStub); + assert.calledOnceWith(guiApi.serverListening, {url: 'http://localhost:4444'}); assert.calledOnceWith(guiApi.serverReady, {url: 'http://localhost:4444'}); }); @@ -77,31 +95,121 @@ describe('lib/gui/server', () => { const toolAdapter = stubToolAdapter(); const {guiApi} = toolAdapter; - await startServer({toolAdapter}); + await startServerAndWait({toolAdapter}); - assert.callOrder(bodyParserStub.json, guiApi.initServer, guiApi.serverReady); + assert.callOrder(bodyParserStub.json, guiApi.initServer, guiApi.serverListening, guiApi.serverReady); }); it('should init server before any static middleware starts', async () => { const toolAdapter = stubToolAdapter(); const {guiApi} = toolAdapter; - await startServer({toolAdapter}); + await startServerAndWait({toolAdapter}); - assert.callOrder(guiApi.initServer, staticMiddleware, guiApi.serverReady); + assert.callOrder(guiApi.initServer, staticMiddleware, guiApi.serverListening, guiApi.serverReady); + }); + + it('should start listening before app initialization is completed', async () => { + let completeInitialization; + App.prototype.initialize.callsFake(() => new Promise(resolve => { + completeInitialization = resolve; + })); + const toolAdapter = stubToolAdapter(); + const {guiApi} = toolAdapter; + + const result = await startServer({toolAdapter}); + + assert.calledOnce(guiApi.serverListening); + assert.notCalled(guiApi.serverReady); + + await new Promise(resolve => setImmediate(resolve)); + completeInitialization(); + await result.ready; + + assert.calledOnce(guiApi.serverReady); }); it('should properly complete app working', async () => { sandbox.stub(process, 'kill'); sandbox.stub(process, 'exit'); - await startServer(); + const result = await startServer(); + await result.ready; - process.emit('SIGTERM'); + onExitCallback(); + await new Promise(resolve => setImmediate(resolve)); assert.calledOnce(App.prototype.finalize); }); + it('should serve the cache before discovery completes and the fresh tree afterwards', async () => { + let finish; + App.prototype.initialize.callsFake(() => new Promise(resolve => { + finish = resolve; + })); + const snapshot = {tree: {suites: {allIds: ['cached']}}, skips: [], timestamp: 1, date: 'date'}; + GuiTreeCache.prototype.read.resolves(snapshot); + const toolAdapter = stubToolAdapter(); + toolAdapter.initGuiHandler = sandbox.stub().resolves(); + const result = await startServer({toolAdapter}); + const init = expressStub.get.withArgs('/init').firstCall.args[1]; + const res = {json: sandbox.stub(), status: sandbox.stub().returnsThis()}; + + await init({query: {cached: '1'}}, res); + assert.calledWithMatch(res.json, {tree: snapshot.tree, isCached: true, autoRun: false}); + assert.notCalled(res.status); + assert.notCalled(toolAdapter.initGuiHandler); + + const guard = expressStub.use.getCalls().map(call => call.args[0]).find(fn => fn?.constructor.name === 'AsyncFunction'); + const next = sandbox.stub(); + await guard({}, res, next); + assert.calledWith(res.status, 503); + assert.notCalled(next); + + res.json.resetHistory(); + const request = init({query: {}}, res); + await new Promise(resolve => setImmediate(resolve)); + assert.notCalled(res.json); + const fresh = {tree: {suites: {allIds: ['fresh']}}}; + appData = fresh; + finish(); + await result.ready; + await request; + assert.calledWith(res.json, fresh); + assert.calledWith(GuiTreeCache.prototype.write, fresh); + await guard({}, res, next); + assert.calledOnce(next); + }); + + it('should return discovery errors without overwriting the cache', async () => { + App.prototype.initialize.rejects(new Error('read failed')); + const result = await startServer(); + await assert.isRejected(result.ready, 'read failed'); + const init = expressStub.get.withArgs('/init').firstCall.args[1]; + const res = {json: sandbox.stub(), status: sandbox.stub().returnsThis()}; + await init({query: {}}, res); + assert.calledWith(res.status, 500); + assert.notCalled(GuiTreeCache.prototype.write); + }); + + it('should wait for the fresh tree when autoRun is enabled even if a cache exists', async () => { + let finish; + App.prototype.initialize.callsFake(() => new Promise(resolve => { + finish = resolve; + })); + GuiTreeCache.prototype.read.resolves({tree: {suites: {allIds: ['cached']}}}); + const result = await startServer({cli: {options: {autoRun: true, port: '4444', hostname: 'localhost'}}}); + const init = expressStub.get.withArgs('/init').firstCall.args[1]; + const res = {json: sandbox.stub(), status: sandbox.stub().returnsThis()}; + const request = init({query: {cached: '1'}}, res); + await new Promise(resolve => setImmediate(resolve)); + assert.notCalled(res.json); + finish(); + await result.ready; + await request; + assert.neverCalledWithMatch(res.json, {isCached: true}); + }); + it('should correctly set json replacer', async () => { const toolAdapter = stubToolAdapter(); diff --git a/test/unit/lib/gui/tool-runner/index.js b/test/unit/lib/gui/tool-runner/index.js index bfd6ea121..eeb8224cd 100644 --- a/test/unit/lib/gui/tool-runner/index.js +++ b/test/unit/lib/gui/tool-runner/index.js @@ -10,6 +10,7 @@ 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 {InitializationPhases} = require('lib/gui/tool-runner'); const {Cache} = require('lib/cache'); const {TestplaneTestAdapter} = require('lib/adapters/test/testplane'); const {TestplaneConfigAdapter} = require('lib/adapters/config/testplane'); @@ -105,6 +106,19 @@ describe('lib/gui/tool-runner/index', () => { afterEach(() => sandbox.restore()); describe('initialize', () => { + it('should report completed initialization phases', async () => { + const onProgress = sandbox.stub().resolves(); + const gui = initGuiReporter({toolAdapter}); + + await gui.initialize(onProgress); + + assert.deepEqual( + onProgress.args.map(([progress]) => progress.phase), + Object.values(InitializationPhases) + ); + onProgress.args.forEach(([progress]) => assert.isAtLeast(progress.duration, 0)); + }); + it('should set values added through api', () => { const htmlReporter = {emit: sandbox.stub(), values: {foo: 'bar'}, config: {}, imagesSaver: {}}; toolAdapter = stubToolAdapter({htmlReporter}); diff --git a/test/unit/lib/gui/tree-cache.ts b/test/unit/lib/gui/tree-cache.ts new file mode 100644 index 000000000..872c5b12d --- /dev/null +++ b/test/unit/lib/gui/tree-cache.ts @@ -0,0 +1,90 @@ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'fs-extra'; +import {gzipSync} from 'node:zlib'; +import {GuiTreeCache} from 'lib/gui/tree-cache'; +import {logger} from 'lib/common-utils'; +import {stubToolAdapter} from 'test/unit/utils'; +import type {ServerArgs} from 'lib/gui'; +import type {ToolRunnerTree} from 'lib/gui/tool-runner'; + +describe('GUI tree cache', () => { + let directory: string; + let args: ServerArgs; + const snapshot = { + tree: { + suites: {allIds: [], allRootIds: [], byId: {}}, + browsers: {allIds: [], byId: {}}, + results: {allIds: [], byId: {}}, + images: {allIds: [], byId: {}} + }, + skips: [], timestamp: 123, date: 'date' + }; + + beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'gui-tree-cache-')); + args = { + paths: [], + cli: {tool: {browser: ['chrome']}, options: {}}, + toolAdapter: stubToolAdapter({reporterConfig: {path: directory}}) + } as unknown as ServerArgs; + }); + + afterEach(async () => { + await fs.remove(directory); + }); + + it('should save a snapshot without config or executable functions', async () => { + const cache = new GuiTreeCache(args); + await cache.write({...snapshot, config: {customGui: {initialize: () => undefined}}} as unknown as ToolRunnerTree); + + assert.deepEqual(await cache.read(), snapshot); + assert.deepEqual(await fs.readdir(directory), ['.gui-tree-cache.json.gz']); + }); + + it('should not reuse a snapshot for a different set of files or browsers', async () => { + await new GuiTreeCache(args).write(snapshot as unknown as ToolRunnerTree); + assert.isNull(await new GuiTreeCache({...args, paths: ['other.ts']}).read()); + args.cli.tool.browser = ['firefox']; + assert.isNull(await new GuiTreeCache(args).read()); + }); + + it('should distinguish grep expressions with different flags', async () => { + args.cli.tool.grep = /test/i; + await new GuiTreeCache(args).write(snapshot as unknown as ToolRunnerTree); + args.cli.tool.grep = /test/; + assert.isNull(await new GuiTreeCache(args).read()); + }); + + it('should return a cache miss when the snapshot is missing or corrupt', async () => { + const cache = new GuiTreeCache(args); + assert.isNull(await cache.read()); + await fs.writeFile(path.join(directory, '.gui-tree-cache.json.gz'), 'broken'); + assert.isNull(await cache.read()); + await fs.writeFile(path.join(directory, '.gui-tree-cache.json.gz'), gzipSync('{}')); + assert.isNull(await cache.read()); + }); + + it('should replace the entire snapshot even when the new tree is empty', async () => { + const cache = new GuiTreeCache(args); + await cache.write({...snapshot, skips: [{browser: 'chrome', suite: 'deleted test'}]} as unknown as ToolRunnerTree); + await cache.write(snapshot as unknown as ToolRunnerTree); + assert.deepEqual(await cache.read(), snapshot); + }); + + it('should preserve the previous snapshot without throwing when writing fails', async () => { + const cache = new GuiTreeCache(args); + await cache.write(snapshot as unknown as ToolRunnerTree); + const sandbox = sinon.createSandbox(); + try { + sandbox.stub(fs, 'outputFile').rejects(new Error('disk full')); + const warn = sandbox.stub(logger, 'warn'); + await cache.write({...snapshot, timestamp: 456} as unknown as ToolRunnerTree); + assert.calledOnce(warn); + assert.deepEqual(await cache.read(), snapshot); + assert.deepEqual(await fs.readdir(directory), ['.gui-tree-cache.json.gz']); + } finally { + sandbox.restore(); + } + }); +}); diff --git a/test/unit/lib/static/modules/actions/lifecycle.ts b/test/unit/lib/static/modules/actions/lifecycle.ts index 3aadb5565..205136d58 100644 --- a/test/unit/lib/static/modules/actions/lifecycle.ts +++ b/test/unit/lib/static/modules/actions/lifecycle.ts @@ -7,6 +7,10 @@ import {LOCAL_DATABASE_NAME, ToolName} from '@/constants'; import actionNames from '@/static/modules/action-names'; import {StaticTestsTreeBuilder} from '@/tests-tree-builder/static'; import type * as actionsModule from '@/static/modules/actions/lifecycle'; +import * as runTestsActions from '@/static/modules/actions/run-tests'; +import guiReducer from '@/static/modules/reducers/gui'; +import defaultState from '@/static/modules/default-state'; +import type {State} from '@/static/new-ui/types/store'; const axios = axiosOriginal as unknown as SinonStubbedInstance; @@ -45,7 +49,7 @@ describe('lib/static/modules/actions/lifecycle', () => { it('should run init action on server', async () => { await actions.thunkInitGuiReport()(dispatch, sinon.stub(), sinon.stub()); - assert.calledOnceWith(axios.get, '/init'); + assert.calledOnceWith(axios.get, '/init?cached=1'); }); it('should fetch database from default html page', async () => { @@ -78,16 +82,124 @@ describe('lib/static/modules/actions/lifecycle', () => { await actions.thunkInitGuiReport()(dispatch, sinon.stub(), sinon.stub()); assert.calledOnceWith(createNotificationError, 'initGuiReport', customGuiError); + assert.calledWith(dispatch, { + type: actionNames.UPDATE_LOADING_TITLE, + payload: 'Failed to initialize Testplane UI' + }); + assert.calledWith(dispatch, { + type: actionNames.UPDATE_LOADING_IS_IN_PROGRESS, + payload: false + }); + assert.calledWith(dispatch, { + type: actionNames.UPDATE_LOADING_VISIBILITY, + payload: true + }); }); it('should init plugins with the config from /init route', async () => { const config = {pluginsEnabled: true, plugins: []}; - axios.get.withArgs('/init').resolves({data: {config, features: []}}); + axios.get.withArgs('/init?cached=1').resolves({data: {config, features: []}}); await actions.thunkInitGuiReport()(dispatch, sinon.stub(), sinon.stub()); assert.calledOnceWith(pluginsStub.loadAll, config); }); + + it('should show the cache without waiting for the database or plugins, then replace it with the fresh tree', async () => { + let finish: (value: unknown) => void = () => assert.fail('The fresh tree has not been requested yet'); + const cached = {tree: {cached: true}, features: [], isCached: true}; + const fresh = {tree: {fresh: true}, features: []}; + axios.get.withArgs('/init?cached=1').resolves({data: cached}); + axios.get.withArgs('/init').returns(new Promise(resolve => { + finish = resolve; + }) as never); + + const initialization = actions.thunkInitGuiReport({isNewUi: true})(dispatch, sinon.stub(), sinon.stub()); + await new Promise(resolve => setTimeout(resolve, 0)); + assert.calledWith(dispatch, { + type: actionNames.INIT_GUI_REPORT, + payload: {...cached, db: null, isNewUi: true} + }); + assert.notCalled(connectToDatabaseStub); + assert.notCalled(pluginsStub.loadAll); + + finish({data: fresh}); + await initialization; + assert.calledWith(dispatch, { + type: actionNames.INIT_GUI_REPORT, + payload: {...fresh, db: {}, isNewUi: true, preserveUiState: true} + }); + }); + + it('should keep the cached tree visible when refreshing fails', async () => { + axios.get.withArgs('/init?cached=1').resolves({data: {features: [], isCached: true}}); + axios.get.withArgs('/init').rejects(new Error('discovery failed')); + await actions.thunkInitGuiReport()(dispatch, sinon.stub(), sinon.stub()); + assert.neverCalledWithMatch(dispatch, {type: actionNames.UPDATE_LOADING_VISIBILITY, payload: true}); + assert.calledOnce(createNotificationError); + }); + + describe('running from the cached tree', () => { + let state: State; + + beforeEach(() => { + state = {...defaultState, app: {...defaultState.app}} as State; + sandbox.stub(axios, 'post').resolves({data: {}}); + dispatch.callsFake(action => { + if (typeof action === 'function') { + return action(dispatch, () => state, null); + } + if (action) { + state = guiReducer(state, action); + } + return action; + }); + axios.get.withArgs('/init?cached=1').resolves({data: {features: [], isCached: true}}); + }); + + it('should wait for the database, plugins and fresh state before submitting the queued run', async () => { + axios.get.withArgs('/init').resolves({data: {features: []}}); + let finishPlugins: () => void = () => assert.fail('Plugins have not started loading'); + pluginsStub.loadAll.returns(new Promise(resolve => { + finishPlugins = resolve; + })); + const initialization = dispatch(actions.thunkInitGuiReport()); + await new Promise(resolve => setTimeout(resolve, 0)); + const tests = [{testName: 'selected', browserName: 'chrome'}]; + await dispatch(runTestsActions.thunkRunTests({tests})); + assert.notCalled(axios.post); + assert.calledOnce(connectToDatabaseStub); + + finishPlugins(); + await initialization; + + assert.calledOnceWith(axios.post, '/run', {tests, repeatCount: 1}); + assert.isFalse(state.app.isGuiInitializing); + assert.isNull(state.app.queuedTestRun); + const freshInit = dispatch.getCalls().find(call => call.args[0]?.payload?.preserveUiState); + assert.isDefined(freshInit); + assert.isTrue(freshInit?.calledBefore(axios.post.firstCall)); + }); + + it('should cancel the queued run when discovery fails', async () => { + let failDiscovery: (error: Error) => void = () => assert.fail('Discovery has not started'); + axios.get.withArgs('/init').returns(new Promise((_resolve, reject) => { + failDiscovery = reject; + }) as never); + const initialization = dispatch(actions.thunkInitGuiReport()); + await new Promise(resolve => setTimeout(resolve, 0)); + await dispatch(runTestsActions.thunkRunTests()); + + failDiscovery(new Error('discovery failed')); + await initialization; + + assert.notCalled(axios.post); + assert.isNull(state.app.queuedTestRun); + assert.isFalse(state.running); + assert.calledWith(dispatch, {type: actionNames.PROCESS_BEGIN}); + assert.calledWith(dispatch, {type: actionNames.SET_AVAILABLE_FEATURES, payload: {features: []}}); + }); + }); }); describe('thunkInitStaticReport', () => { diff --git a/test/unit/lib/static/modules/actions/run-tests.ts b/test/unit/lib/static/modules/actions/run-tests.ts index 2e7f45c1b..bc04d58c2 100644 --- a/test/unit/lib/static/modules/actions/run-tests.ts +++ b/test/unit/lib/static/modules/actions/run-tests.ts @@ -3,6 +3,9 @@ import actionNames from '@/static/modules/action-names'; import sinon, {SinonStub, SinonStubbedInstance} from 'sinon'; import proxyquire from 'proxyquire'; import axiosOriginal from 'axios'; +import guiReducer from '@/static/modules/reducers/gui'; +import defaultState from '@/static/modules/default-state'; +import type {State} from '@/static/new-ui/types/store'; const axios = axiosOriginal as unknown as SinonStubbedInstance; @@ -32,6 +35,86 @@ describe('lib/static/modules/actions/run-tests', () => { sandbox.restore(); }); + describe('queued runs', () => { + let state: State; + + beforeEach(() => { + state = {...defaultState, app: {...defaultState.app, isGuiInitializing: true}} as State; + dispatch.callsFake(action => { + if (typeof action === 'function') { + return action(dispatch, () => state, null); + } + if (action) { + state = guiReducer(state, action); + } + return action; + }); + }); + + it('should snapshot the first selection and repeat count without sending a request', async () => { + state.repeatCount = 3; + const tests = [{testName: 'selected', browserName: 'chrome'}]; + await dispatch(actions.thunkRunTests({tests})); + tests[0].testName = 'changed'; + state.repeatCount = 5; + await dispatch(actions.thunkRunTests({tests})); + await dispatch(actions.thunkRunQueuedTests()); + + assert.notCalled(axios.post); + assert.deepEqual(state.app.queuedTestRun, { + tests: [{testName: 'selected', browserName: 'chrome'}], repeatCount: 3 + }); + assert.isTrue(state.processing); + assert.isTrue(state.running); + }); + + it('should submit the queued request exactly once after initialization', async () => { + const tests = [{testName: 'selected', browserName: 'chrome'}]; + await dispatch(actions.thunkRunTests({tests, repeatCount: 3})); + dispatch({type: actionNames.INIT_GUI_REPORT, payload: {isCached: false}}); + await dispatch(actions.thunkRunQueuedTests()); + await dispatch(actions.thunkRunQueuedTests()); + + assert.calledOnceWith(axios.post, '/run', {tests, repeatCount: 3}); + assert.isNull(state.app.queuedTestRun); + }); + + it('should cancel a queued run locally without stopping Testplane initialization', async () => { + await dispatch(actions.thunkRunAllTests()); + assert.neverCalledWithMatch(dispatch, {type: actionNames.RUN_ALL_TESTS}); + await dispatch(actions.thunkStopTests()); + dispatch({type: actionNames.INIT_GUI_REPORT, payload: {isCached: false}}); + await dispatch(actions.thunkRunQueuedTests()); + + assert.notCalled(axios.post); + assert.isNull(state.app.queuedTestRun); + assert.isFalse(state.running); + assert.isFalse(state.processing); + }); + + it('should preserve Run All semantics when draining the queue', async () => { + await dispatch(actions.thunkRunAllTests()); + dispatch({type: actionNames.INIT_GUI_REPORT, payload: {isCached: false}}); + await dispatch(actions.thunkRunQueuedTests()); + + assert.calledOnceWith(axios.post, '/run', {tests: [], repeatCount: 1}); + assert.calledWith(dispatch, {type: actionNames.RUN_ALL_TESTS}); + }); + + it('should clear pending state and report a rejected run request', async () => { + const error = new Error('run failed'); + axios.post.rejects(error); + await dispatch(actions.thunkRunTests()); + dispatch({type: actionNames.INIT_GUI_REPORT, payload: {isCached: false}}); + await dispatch(actions.thunkRunQueuedTests()); + + assert.isNull(state.app.queuedTestRun); + assert.isFalse(state.running); + assert.isFalse(state.processing); + assert.calledWith(createNotificationErrorStub, 'runTests', error); + }); + }); + describe('thunkRunTest', () => { it('should retry passed test', async () => { dispatch.callsFake((action) => { diff --git a/test/unit/lib/static/modules/reducers/index.js b/test/unit/lib/static/modules/reducers/index.js index 99c5bcf81..18102a3c0 100644 --- a/test/unit/lib/static/modules/reducers/index.js +++ b/test/unit/lib/static/modules/reducers/index.js @@ -1,8 +1,46 @@ const reducer = require('lib/static/modules/reducers').default; const actionNames = require('lib/static/modules/action-names').default; const defaultState = require('lib/static/modules/default-state').default; +const {mkSuite, mkBrowser, mkResult, mkStateTree} = require('../../state-utils'); +const {RunTestsFeature, EditScreensFeature} = require('lib/constants'); describe('lib/static/modules/reducers', () => { + it('should allow queuing from the cache and preserve the pending run and filter when updating', () => { + const makePayload = isCached => ({ + isCached, + tree: mkStateTree({ + suitesById: mkSuite({id: 'suite', browserIds: ['browser']}), + browsersById: mkBrowser({id: 'browser', parentId: 'suite', resultIds: ['result']}), + resultsById: mkResult({id: 'result', parentId: 'browser'}) + }), + config: {...defaultState.config, errorPatterns: []}, + skips: [], features: [], apiValues: {}, db: null, isNewUi: true + }); + let state = reducer(undefined, {type: actionNames.INIT_GUI_REPORT, payload: makePayload(true)}); + assert.isFalse(state.processing); + assert.isTrue(state.app.isGuiInitializing); + assert.deepEqual(state.app.availableFeatures, [RunTestsFeature]); + const queuedTestRun = {tests: [{testName: 'test', browserName: 'browser'}], repeatCount: 2}; + state = reducer(state, {type: actionNames.QUEUE_TEST_RUN, payload: queuedTestRun}); + assert.isTrue(state.running); + assert.isTrue(state.processing); + state = reducer(state, {type: actionNames.VIEW_UPDATE_FILTER_BY_NAME, payload: {data: 'my test'}}); + state = reducer(state, { + type: actionNames.INIT_GUI_REPORT, + payload: {...makePayload(false), preserveUiState: true} + }); + assert.isTrue(state.processing); + assert.isTrue(state.running); + assert.isFalse(state.app.isGuiInitializing); + assert.deepEqual(state.app.queuedTestRun, queuedTestRun); + assert.includeDeepMembers(state.app.availableFeatures, [RunTestsFeature, EditScreensFeature]); + assert.equal(state.app.nameFilter, 'my test'); + state = reducer(state, {type: actionNames.CLEAR_QUEUED_TEST_RUN}); + assert.isFalse(state.running); + assert.isFalse(state.processing); + assert.isNull(state.app.queuedTestRun); + }); + describe('static accepter editing while processing', () => { [ actionNames.STATIC_ACCEPTER_DELAY_SCREENSHOT, @@ -17,6 +55,10 @@ describe('lib/static/modules/reducers', () => { assert.strictEqual(newState, state); }); + it(`should ignore ${type} while the cached tree is shown`, () => { + const state = {processing: false, app: {isGuiInitializing: true}}; + assert.strictEqual(reducer(state, {type, payload: ['image-id']}), state); + }); }); it('should resume editing after processing ends', () => { diff --git a/test/unit/lib/static/modules/reducers/tree/index.js b/test/unit/lib/static/modules/reducers/tree/index.js index 93a5f8590..6dbc4d4cc 100644 --- a/test/unit/lib/static/modules/reducers/tree/index.js +++ b/test/unit/lib/static/modules/reducers/tree/index.js @@ -8,6 +8,30 @@ const {mkSuite, mkBrowser, mkResult, mkImage, mkStateTree, mkStateView, mkStateP const {ErrorName} = require('lib/errors'); describe('lib/static/modules/reducers/tree', () => { + it('should preserve existing selections, add new tests and remove deleted tests when replacing the cache', () => { + const makeTree = ids => mkStateTree({ + suitesById: mkSuite({id: 's1', browserIds: ids}), + browsersById: Object.assign({}, ...ids.map(id => mkBrowser({id, parentId: 's1', resultIds: [`r-${id}`]}))), + resultsById: Object.assign({}, ...ids.map(id => mkResult({id: `r-${id}`, parentId: id, status: SUCCESS}))) + }); + const state = reducer({app: mkStatePageFilters({}), view: mkStateView({})}, { + type: actionNames.INIT_GUI_REPORT, payload: {tree: makeTree(['keep', 'deleted'])} + }); + state.tree.browsers.stateById.keep.checkStatus = CHECKED; + state.tree.suites.stateById.s1.shouldBeOpened = false; + + const updated = reducer(state, { + type: actionNames.INIT_GUI_REPORT, + payload: {tree: makeTree(['keep', 'added']), preserveUiState: true} + }); + + assert.deepEqual(updated.tree.browsers.allIds, ['keep', 'added']); + assert.equal(updated.tree.browsers.stateById.keep.checkStatus, CHECKED); + assert.equal(updated.tree.browsers.stateById.added.checkStatus, UNCHECKED); + assert.isFalse(updated.tree.suites.stateById.s1.shouldBeOpened); + assert.notProperty(updated.tree.browsers.stateById, 'deleted'); + assert.equal(updated.tree.suites.stateById.s1.checkStatus, INDETERMINATE); + }); [actionNames.INIT_GUI_REPORT, actionNames.INIT_STATIC_REPORT].forEach((actionName) => { describe(`${actionName} action`, () => { it('should set status from filtered browsers to parent suites', () => { diff --git a/test/unit/lib/static/new-ui/components/QueuedTestRunNotification.tsx b/test/unit/lib/static/new-ui/components/QueuedTestRunNotification.tsx new file mode 100644 index 000000000..406ef87f4 --- /dev/null +++ b/test/unit/lib/static/new-ui/components/QueuedTestRunNotification.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import {Provider} from 'react-redux'; +import {act, render} from '@testing-library/react'; +import sinon from 'sinon'; +import {Toaster, ToasterProvider} from '@gravity-ui/uikit'; +import {QueuedTestRunNotification} from '@/static/new-ui/components/QueuedTestRunNotification'; +import actionNames from '@/static/modules/action-names'; +import {mkRealStore} from '../../utils'; +import defaultState from '@/static/modules/default-state'; +import type {State} from '@/static/new-ui/types/store'; + +describe('', () => { + it('should offer cancellation and remove the notification when the queue is cleared', () => { + const store = mkRealStore({initialState: defaultState as State, middlewares: []}); + const toaster = new Toaster(); + const add = sinon.spy(toaster, 'add'); + const remove = sinon.spy(toaster, 'remove'); + const component = render( + + ); + + assert.notCalled(add); + act(() => { + store.dispatch({type: actionNames.QUEUE_TEST_RUN, payload: {tests: [], repeatCount: 1}}); + }); + + assert.calledOnce(add); + const notification = add.firstCall.args[0]; + assert.isFalse(notification.autoHiding); + assert.equal(notification.actions?.[0].label, 'Cancel'); + act(() => { + notification.actions?.[0].onClick(); + }); + + assert.isNull(store.getState().app.queuedTestRun); + assert.isFalse(store.getState().running); + assert.calledWith(remove, 'queued-test-run'); + component.unmount(); + }); +}); diff --git a/test/unit/utils.js b/test/unit/utils.js index 47a49b2fe..efb73be91 100644 --- a/test/unit/utils.js +++ b/test/unit/utils.js @@ -71,6 +71,7 @@ function stubToolAdapter({ handleTestResults: sinon.stub(), guiApi: { initServer: sinon.stub(), + serverListening: sinon.stub(), serverReady: sinon.stub() } };