Skip to content

Commit 4894819

Browse files
authored
e2e: add support for perf metrics & start logging a few scenarios (#8969)
### Summary Our shared fixtures file (`_test.setup.ts`) was getting unwieldy, so I reorganized it to improve maintainability. Most fixtures (though not all) are now grouped under `test/e2e/fixtures/test-setup/<area>/`, making it easier to locate and manage them by scope. I also introduced a new `metrics` fixture to support sending metric data to the e2e-test-insights API. While the dashboard doesn’t surface this data yet, we’re starting to collect it now in preparation. A few tests that were previously logging event timing have been updated to send this data to the API. I also added a metrics README to make it clear how to record/send metrics for new feature areas. ### QA Notes @:performance @:web @:win
1 parent 9073c7f commit 4894819

24 files changed

+1498
-434
lines changed

build/secrets/.secrets.baseline

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,6 @@
9090
{
9191
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
9292
},
93-
{
94-
"path": "detect_secrets.filters.common.is_baseline_file",
95-
"filename": "build/secrets/.secrets.baseline"
96-
},
9793
{
9894
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
9995
},
@@ -1738,5 +1734,5 @@
17381734
}
17391735
]
17401736
},
1741-
"generated_at": "2025-08-25T14:05:12Z"
1737+
"generated_at": "2025-08-25T17:39:02Z"
17421738
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
3+
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import path = require('path');
7+
import { join } from 'path';
8+
import * as fs from 'fs';
9+
import { constants, access, rm, mkdir, rename } from 'fs/promises';
10+
import * as os from 'os';
11+
import * as playwright from '@playwright/test';
12+
import { Application, ApplicationOptions, MultiLogger, createApp, copyFixtureFile, Quality, getRandomUserDataDir } from '../../infra';
13+
import { SPEC_NAME, setFixtureScreenshot, ROOT_PATH, TEMP_DIR } from './constants';
14+
15+
export interface CustomTestOptions {
16+
web: boolean;
17+
artifactDir: string;
18+
headless?: boolean;
19+
}
20+
21+
export interface AppFixtureOptions {
22+
options: ApplicationOptions;
23+
logsPath: string;
24+
logger: MultiLogger;
25+
workerInfo: playwright.WorkerInfo;
26+
}
27+
28+
export function OptionsFixture() {
29+
return async (logsPath: string, logger: MultiLogger, snapshots: boolean, project: CustomTestOptions) => {
30+
const TEST_DATA_PATH = join(os.tmpdir(), 'vscsmoke');
31+
const EXTENSIONS_PATH = join(TEST_DATA_PATH, 'extensions-dir');
32+
const WORKSPACE_PATH = join(TEST_DATA_PATH, 'qa-example-content');
33+
const SPEC_CRASHES_PATH = join(ROOT_PATH, '.build', 'crashes', project.artifactDir, TEMP_DIR);
34+
35+
// get the version from package.json
36+
const packageJsonPath = join(ROOT_PATH, 'package.json');
37+
const packageJson = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf-8'));
38+
const packageVersion = packageJson.version || '0.0.0';
39+
const version = {
40+
major: parseInt(packageVersion.split('.')[0], 10),
41+
minor: parseInt(packageVersion.split('.')[1], 10),
42+
patch: parseInt(packageVersion.split('.')[2], 10),
43+
};
44+
45+
const options: ApplicationOptions = {
46+
codePath: process.env.BUILD,
47+
workspacePath: WORKSPACE_PATH,
48+
userDataDir: join(TEST_DATA_PATH, 'd'),
49+
extensionsPath: EXTENSIONS_PATH,
50+
logger,
51+
logsPath,
52+
crashesPath: SPEC_CRASHES_PATH,
53+
verbose: !!process.env.VERBOSE,
54+
remote: !!process.env.REMOTE,
55+
web: project.web,
56+
headless: project.headless,
57+
tracing: true,
58+
snapshots,
59+
quality: Quality.Dev,
60+
version
61+
};
62+
63+
options.userDataDir = getRandomUserDataDir(options);
64+
65+
return options;
66+
};
67+
}
68+
69+
export function UserDataDirFixture() {
70+
return async (options: ApplicationOptions) => {
71+
const userDir = options.web ? join(options.userDataDir, 'data', 'User') : join(options.userDataDir, 'User');
72+
process.env.PLAYWRIGHT_USER_DATA_DIR = userDir;
73+
74+
// Copy keybindings and settings fixtures to the user data directory
75+
await copyFixtureFile('keybindings.json', userDir, true);
76+
77+
const settingsFileName = 'settings.json';
78+
if (fs.existsSync('/.dockerenv')) {
79+
80+
const fixturesDir = path.join(ROOT_PATH, 'test/e2e/fixtures');
81+
const settingsFile = path.join(fixturesDir, 'settings.json');
82+
83+
const mergedSettings = {
84+
...JSON.parse(fs.readFileSync(settingsFile, 'utf8')),
85+
...JSON.parse(fs.readFileSync(path.join(fixturesDir, 'settingsDocker.json'), 'utf8')),
86+
};
87+
88+
// Overwrite file
89+
fs.writeFileSync(settingsFile, JSON.stringify(mergedSettings, null, 2));
90+
}
91+
92+
await copyFixtureFile(settingsFileName, userDir);
93+
94+
return userDir;
95+
};
96+
}
97+
98+
export function AppFixture() {
99+
return async (fixtureOptions: AppFixtureOptions, use: (arg0: Application) => Promise<void>) => {
100+
const { options, logsPath, logger, workerInfo } = fixtureOptions;
101+
const app = createApp(options);
102+
103+
try {
104+
await app.start();
105+
await app.workbench.sessions.expectNoStartUpMessaging();
106+
107+
await use(app);
108+
} catch (error) {
109+
// capture a screenshot on failure
110+
const screenshotPath = path.join(logsPath, 'app-start-failure.png');
111+
try {
112+
const page = app.code?.driver?.page;
113+
if (page) {
114+
const screenshot = await page.screenshot({ path: screenshotPath });
115+
setFixtureScreenshot(screenshot);
116+
}
117+
} catch {
118+
// ignore
119+
}
120+
121+
throw error; // re-throw the error to ensure test failure
122+
} finally {
123+
await app.stop();
124+
125+
// rename the temp logs dir to the spec name (if available)
126+
const specLogsPath = path.join(path.dirname(logsPath), SPEC_NAME || `worker-${workerInfo.workerIndex}`);
127+
await moveAndOverwrite(logger, logsPath, specLogsPath);
128+
}
129+
};
130+
}
131+
132+
async function moveAndOverwrite(logger: MultiLogger, sourcePath: string, destinationPath: string) {
133+
try {
134+
await access(sourcePath, constants.F_OK);
135+
} catch {
136+
console.error(`moveAndOverwrite: source path does not exist: ${sourcePath}`);
137+
return;
138+
}
139+
140+
// check if the destination exists and delete it if so
141+
try {
142+
await access(destinationPath, constants.F_OK);
143+
await rm(destinationPath, { recursive: true, force: true });
144+
} catch (err) { }
145+
146+
// ensure parent directory of destination path exists
147+
const destinationDir = path.dirname(destinationPath);
148+
await mkdir(destinationDir, { recursive: true });
149+
150+
// rename source to destination
151+
try {
152+
await rename(sourcePath, destinationPath);
153+
logger.setPath(destinationPath);
154+
logger.log('Logger path updated to:', destinationPath);
155+
} catch (err) {
156+
logger.log(`moveAndOverwrite: failed to move ${sourcePath} to ${destinationPath}:`, err);
157+
}
158+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
3+
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { join } from 'path';
7+
import { randomUUID } from 'crypto';
8+
9+
// Constants used across test fixtures
10+
export const TEMP_DIR = `temp-${randomUUID()}`;
11+
export const ROOT_PATH = process.cwd();
12+
export const LOGS_ROOT_PATH = join(ROOT_PATH, 'test-logs');
13+
14+
// Global state variables that need to be mutable
15+
export let SPEC_NAME = '';
16+
export let fixtureScreenshot: Buffer;
17+
18+
export function setSpecName(name: string) {
19+
SPEC_NAME = name;
20+
}
21+
22+
export function setFixtureScreenshot(screenshot: Buffer) {
23+
fixtureScreenshot = screenshot;
24+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
3+
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as playwright from '@playwright/test';
7+
import * as path from 'path';
8+
import { test } from '@playwright/test';
9+
import { Application } from '../../infra';
10+
11+
/**
12+
* Create file operation helpers for opening files and folders
13+
*/
14+
export function FileOperationsFixture(app: Application) {
15+
return {
16+
openFile: async (filePath: string, waitForFocus = true) => {
17+
await test.step(`Open file: ${path.basename(filePath)}`, async () => {
18+
await app.workbench.quickaccess.openFile(path.join(app.workspacePathOrFolder, filePath), waitForFocus);
19+
});
20+
},
21+
22+
openDataFile: async (filePath: string) => {
23+
await test.step(`Open data file: ${path.basename(filePath)}`, async () => {
24+
await app.workbench.quickaccess.openDataFile(path.join(app.workspacePathOrFolder, filePath));
25+
});
26+
},
27+
28+
openFolder: async (folderPath: string) => {
29+
await test.step(`Open folder: ${folderPath}`, async () => {
30+
await app.workbench.hotKeys.openFolder();
31+
await playwright.expect(app.workbench.quickInput.quickInputList.locator('a').filter({ hasText: '..' })).toBeVisible();
32+
33+
const folderNames = folderPath.split('/');
34+
35+
for (const folderName of folderNames) {
36+
const quickInputOption = app.workbench.quickInput.quickInputResult.getByText(folderName);
37+
38+
// Ensure we are ready to select the next folder
39+
const timeoutMs = 30000;
40+
const retryInterval = 2000;
41+
const maxRetries = Math.ceil(timeoutMs / retryInterval);
42+
43+
for (let i = 0; i < maxRetries; i++) {
44+
try {
45+
await playwright.expect(quickInputOption).toBeVisible({ timeout: retryInterval });
46+
// Success — exit loop
47+
break;
48+
} catch (error) {
49+
// Press PageDown if not found
50+
await app.code.driver.page.keyboard.press('PageDown');
51+
52+
// If last attempt, rethrow
53+
if (i === maxRetries - 1) {
54+
throw error;
55+
}
56+
}
57+
}
58+
59+
await app.workbench.quickInput.quickInput.pressSequentially(folderName + '/');
60+
61+
// Ensure next folder is no longer visible
62+
await playwright.expect(quickInputOption).not.toBeVisible();
63+
}
64+
65+
await app.workbench.quickInput.clickOkButton();
66+
});
67+
}
68+
};
69+
}

test/e2e/fixtures/test-setup/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Re-export constants
2+
export { TEMP_DIR, LOGS_ROOT_PATH, fixtureScreenshot, setSpecName } from './constants';
3+
export * from './metrics.fixtures';
4+
export * from './reporting.fixtures';
5+
export * from './settings.fixtures';
6+
export * from './app.fixtures';
7+
export * from './file-ops.fixtures';
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
3+
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { recordDataFileLoad, recordDataFilter, recordDataSort, recordToCode, type DataExplorerAutoContext, type DataExplorerShortcutOptions } from '../../utils/metrics/metric-data-explorer.js';
7+
import { recordRunCell, type NotebookShortcutOptions } from '../../utils/metrics/metric-notebooks.js';
8+
import { type RecordMetric, type MetricResult, type MetricContext, type MetricTargetType } from '../../utils/metrics/metric-base.js';
9+
import { Application, MultiLogger } from '../../infra/index.js';
10+
11+
/**
12+
* Creates a metrics recorder for performance testing
13+
*/
14+
export function MetricsFixture(app: Application, logger: MultiLogger): RecordMetric {
15+
const dataExplorerAutoContext: DataExplorerAutoContext = {
16+
getRowCount: async () => {
17+
return app.workbench.dataExplorer.grid.getRowCount();
18+
},
19+
getColumnCount: async () => {
20+
return app.workbench.dataExplorer.grid.getColumnCount();
21+
}
22+
};
23+
24+
return {
25+
dataExplorer: {
26+
loadData: async <T>(
27+
operation: () => Promise<T>,
28+
targetType: MetricTargetType,
29+
options?: DataExplorerShortcutOptions
30+
): Promise<MetricResult<T>> => {
31+
return recordDataFileLoad(operation, targetType, !!app.code.electronApp, logger, dataExplorerAutoContext, options);
32+
},
33+
filter: async <T>(
34+
operation: () => Promise<T>,
35+
targetType: MetricTargetType,
36+
options?: DataExplorerShortcutOptions
37+
): Promise<MetricResult<T>> => {
38+
return recordDataFilter(operation, targetType, !!app.code.electronApp, logger, dataExplorerAutoContext, options);
39+
},
40+
sort: async <T>(
41+
operation: () => Promise<T>,
42+
targetType: MetricTargetType,
43+
options?: DataExplorerShortcutOptions
44+
): Promise<MetricResult<T>> => {
45+
return recordDataSort(operation, targetType, !!app.code.electronApp, logger, dataExplorerAutoContext, options);
46+
},
47+
toCode: async <T>(
48+
operation: () => Promise<T>,
49+
targetType: MetricTargetType,
50+
options?: DataExplorerShortcutOptions
51+
): Promise<MetricResult<T>> => {
52+
return recordToCode(operation, targetType, !!app.code.electronApp, logger, dataExplorerAutoContext, options);
53+
}
54+
},
55+
notebooks: {
56+
runCell: async <T>(
57+
operation: () => Promise<T>,
58+
targetType: MetricTargetType,
59+
language?: string,
60+
description?: string,
61+
context?: MetricContext | (() => Promise<MetricContext>)
62+
): Promise<MetricResult<T>> => {
63+
const options: NotebookShortcutOptions = {
64+
description,
65+
additionalContext: context
66+
};
67+
return recordRunCell(operation, targetType, !!app.code.electronApp, logger, language, options);
68+
}
69+
}
70+
};
71+
}

0 commit comments

Comments
 (0)