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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions lib/gui/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ export interface ServerReadyData {
url: string;
}

export interface ServerStartData extends ServerReadyData {
ready: Promise<void>;
}

export interface InitializationProgress {
phase: string;
duration: number;
}

export type InitializationProgressHandler = (progress: InitializationProgress) => void | Promise<void>;

export class GuiApi {
private _gui: ApiFacade;

Expand All @@ -20,6 +31,14 @@ export class GuiApi {
await this._gui.emitAsync(this._gui.events.SERVER_INIT, server);
}

async serverListening(data: ServerReadyData): Promise<void> {
await this._gui.emitAsync(this._gui.events.SERVER_LISTENING, data);
}

async initializationProgress(data: InitializationProgress): Promise<void> {
await this._gui.emitAsync(this._gui.events.INITIALIZATION_PROGRESS, data);
}

async serverReady(data: ServerReadyData): Promise<void> {
await this._gui.emitAsync(this._gui.events.SERVER_READY, data);
}
Expand Down
5 changes: 3 additions & 2 deletions lib/gui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,8 +22,8 @@ export class App {
return this._toolRunner.tree;
}

async initialize(): Promise<void> {
return await this._toolRunner.initialize();
async initialize(onProgress?: InitializationProgressHandler): Promise<void> {
return await this._toolRunner.initialize(onProgress);
}

async finalize(): Promise<void> {
Expand Down
2 changes: 2 additions & 0 deletions lib/gui/constants/gui-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
8 changes: 7 additions & 1 deletion lib/gui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
122 changes: 105 additions & 17 deletions lib/gui/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -32,18 +33,33 @@ type TimeTravelConfig = Config['timeTravel'];

const originalBrowserConfigs = new Map<string, {timeTravel?: TimeTravelConfig, saveHistoryMode?: Config['saveHistoryMode']}>();

export type GetInitResponse = (ToolRunnerTree & {customGuiError?: CustomGuiError} & { browserFeatures: Record<string, BrowserFeature[]>, features: Feature[]}) | null;
export type GetInitResponse = (ToolRunnerTree & {customGuiError?: CustomGuiError; isCached?: boolean} & { browserFeatures: Record<string, BrowserFeature[]>, features: Feature[]}) | null;

export const start = async (args: ServerArgs): Promise<ServerReadyData> => {
export const start = async (args: ServerArgs): Promise<ServerStartData> => {
const {toolAdapter} = args;
const {reporterConfig, guiApi} = toolAdapter;

if (!guiApi) {
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<App>;
initialization?: Promise<void>;
cachedTree?: ReturnType<GuiTreeCache['read']>;
initialized?: boolean;
} = {};

const getApp = async (): Promise<App> => {
if (!state.appCreated) {
throw new Error('GUI initialization has not started');
}

return state.appCreated;
};

server.use(bodyParser.json({limit: MAX_REQUEST_SIZE}));

Expand All @@ -69,27 +85,61 @@ export const start = async (args: ServerArgs): Promise<ServerReadyData> => {
}
});

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();
}

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,
Expand All @@ -103,6 +153,23 @@ export const start = async (args: ServerArgs): Promise<ServerReadyData> => {
}
});

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<string, unknown, UpdateTimeTravelSettingsResponse, UpdateTimeTravelSettingsRequest>('/update-time-travel-settings', (req, res) => {
try {
if (toolAdapter.toolName !== ToolName.Testplane) {
Expand Down Expand Up @@ -270,13 +337,14 @@ export const start = async (args: ServerArgs): Promise<ServerReadyData> => {
});

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}`);
Expand All @@ -293,8 +361,6 @@ export const start = async (args: ServerArgs): Promise<ServerReadyData> => {
}
});

await app.initialize();

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

const {actualPort, hostnameForUrl} = await listenWithFallback({
Expand All @@ -312,8 +378,30 @@ export const start = async (args: ServerArgs): Promise<ServerReadyData> => {
}

const data = {url: `http://${hostnameForUrl}:${actualPort}`};
state.cachedTree = treeCache.read();
state.appCreated = new Promise<App>((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};
};
73 changes: 52 additions & 21 deletions lib/gui/tool-runner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GuiCliOptions, 'autoRun'> & {
features: Feature[];
Expand Down Expand Up @@ -119,35 +128,57 @@ export class ToolRunner {
});
}

async initialize(): Promise<void> {
await mergeDatabasesForReuse(this._reportPath);
await prepareLocalDatabase(this._reportPath);
async initialize(onProgress?: InitializationProgressHandler): Promise<void> {
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<T>(
phase: string,
onProgress: InitializationProgressHandler | undefined,
action: () => T | Promise<T>
): Promise<T> {
const startedAt = Date.now();

try {
return await action();
} finally {
await onProgress?.({phase, duration: Date.now() - startedAt});
}
}

async _readTests(): Promise<TestCollectionAdapter> {
Expand Down
Loading