-
Notifications
You must be signed in to change notification settings - Fork 449
Expand file tree
/
Copy pathcreateDesktopTest.ts
More file actions
379 lines (347 loc) · 15.4 KB
/
createDesktopTest.ts
File metadata and controls
379 lines (347 loc) · 15.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
/*
* Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
// This is Node.js test infrastructure, not extension code
import type { WorkerFixtures, TestFixtures } from './desktopFixtureTypes';
import { test as base, _electron as electron } from '@playwright/test';
import { downloadAndUnzipVSCode, resolveCliPathFromVSCodeExecutablePath } from '@vscode/test-electron';
import { spawnSync, type ChildProcess } from 'node:child_process';
import * as crypto from 'node:crypto';
import { existsSync, readdirSync } from 'node:fs';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { filterErrors } from '../utils/helpers';
import { resolveRepoRoot } from '../utils/repoRoot';
import { createEmptyTestWorkspace, createTestWorkspace } from './desktopWorkspace';
/** Close timeout before force-kill (non-macOS-CI path). */
const CLOSE_TIMEOUT_MS = 5000;
/**
* Force-kill an Electron process tree on macOS/Linux. Playwright spawns Electron
* with detached:true, giving it its own process group (PGID = PID). Sending
* SIGKILL to -pid kills the entire group (main + GPU + crashpad + utility).
* Then destroy stdio pipes so Node.js emits the ChildProcess 'exit' event —
* without this, Playwright's worker teardown waits for pipe EOF and times out.
*/
const forceKillProcessGroup = (proc: ChildProcess): void => {
const { pid } = proc;
if (typeof pid !== 'number') return;
try {
process.kill(-pid, 'SIGKILL');
} catch {}
proc.stdin?.destroy();
proc.stdout?.destroy();
proc.stderr?.destroy();
};
type CreateDesktopTestOptions = {
/** __dirname from the calling extension's fixture file (e.g., '<pkg>/test/playwright/fixtures') */
fixturesDir: string;
/** Scratch alias for workspace `.sfdx/config.json` `target-org`. Omit or `undefined` → no `config.json` (no org). */
orgAlias?: string;
/** When true, use empty workspace (no sfdx-project.json). Default false. */
emptyWorkspace?: boolean;
/** Additional extension directory names to load (ex: ['salesforcedx-vscode-metadata'] for apex-testing "SFDX: Create Apex Class") */
additionalExtensionDirs?: string[];
/** When false, do not pass --disable-extensions (needed when loading multiple dev extensions). Default true. */
disableOtherExtensions?: boolean;
/** Optional user settings to write to User/settings.json (e.g. to reduce GitHub/Git prompts). */
userSettings?: Record<string, unknown>;
/**
* When true, install VSIXs and launch VS Code with --extensions-dir instead of --extensionDevelopmentPath.
* Exercises the real shipping artifact (bundled dist/, packageUpdates, .vscodeignore).
* Defaults to `process.env.E2E_FROM_VSIX === '1'` — set that env var in CI to enable without code changes.
*/
useVsix?: boolean;
};
/**
* Resolve *.vsix files for the given package directories (relative to repo root packages/).
* Fails loudly if a package has 0 or >1 VSIX (wireit should produce exactly one).
*/
const resolveVsixPaths = (repoRoot: string, packageDirs: string[]): string[] =>
packageDirs.map(dir => {
const pkgDir = path.join(repoRoot, 'packages', dir);
const vsixFiles = existsSync(pkgDir) ? readdirSync(pkgDir).filter(f => f.endsWith('.vsix')) : [];
if (vsixFiles.length !== 1) {
throw new Error(
`Expected exactly 1 VSIX in packages/${dir}/ but found ${vsixFiles.length}: [${vsixFiles.join(', ')}]. ` +
`Run 'npm run vscode:package -w ${dir}' first.`
);
}
return path.join(pkgDir, vsixFiles[0]);
});
/**
* Compute a cache key from the combined sha256 of all VSIX files.
* Uses file sizes+mtimes as a fast proxy — avoids reading multi-MB VSIX bytes.
*/
const computeVsixCacheKey = async (vsixPaths: string[]): Promise<string> => {
const hash = crypto.createHash('sha256');
for (const p of vsixPaths) {
const stat = await fs.stat(p);
hash.update(`${p}:${stat.size}:${stat.mtimeMs}`);
}
return hash.digest('hex').slice(0, 16);
};
/**
* Install VSIXs into a hash-keyed cache dir under <repoRoot>/.vscode-test/ext-<hash>/.
* Each worker gets its own per-pid tmp dir to avoid concurrent install conflicts.
* Atomic rename to final cache dir; second worker to finish sees the dir exists and cleans up.
* Idempotent: skips if <dir>/extensions.json already exists (first worker won).
*/
const installVsixsToCache = async (
cacheDir: string,
vsixPaths: string[],
vscodeExecutable: string
): Promise<void> => {
const extensionsJson = path.join(cacheDir, 'extensions.json');
if (existsSync(extensionsJson)) {
return; // already installed — fast path
}
// Per-pid tmp dir: avoids concurrent workers writing to the same directory
const tmpDir = `${cacheDir}.tmp.${process.pid}`;
await fs.rm(tmpDir, { recursive: true, force: true });
await fs.mkdir(tmpDir, { recursive: true });
const cli = resolveCliPathFromVSCodeExecutablePath(vscodeExecutable);
const result = spawnSync(
cli,
[
'--extensions-dir',
tmpDir,
'--user-data-dir',
path.join(tmpDir, '.ud'),
...vsixPaths.flatMap(p => ['--install-extension', p])
],
{ stdio: 'inherit', shell: process.platform === 'win32' }
);
if (result.status !== 0) {
await fs.rm(tmpDir, { recursive: true, force: true });
throw new Error(`VSIX install failed (exit ${result.status}). VSIXs: ${vsixPaths.join(', ')}`);
}
// Atomic rename: first worker wins; others clean up their own tmp and use the winner's dir
try {
await fs.rename(tmpDir, cacheDir);
} catch {
await fs.rm(tmpDir, { recursive: true, force: true });
}
};
/** Creates a Playwright test instance configured for desktop Electron testing with services extension */
export const createDesktopTest = (options: CreateDesktopTestOptions) => {
const {
fixturesDir,
orgAlias,
emptyWorkspace = false,
additionalExtensionDirs = [],
disableOtherExtensions = true,
userSettings
} = options;
const useVsix = options.useVsix ?? process.env.E2E_FROM_VSIX === '1';
// Current package name = the packages/<dir> that owns this fixture file.
// fixturesDir is e.g. <repoRoot>/packages/salesforcedx-vscode-org-browser/test/playwright/fixtures
const packageDir = path.basename(path.resolve(fixturesDir, '..', '..', '..'));
const test = base.extend<TestFixtures, WorkerFixtures>({
// Download VS Code once per worker (cached at repo root .vscode-test/)
vscodeExecutable: [
async ({}, use): Promise<void> => {
const repoRoot = resolveRepoRoot(fixturesDir);
const cachePath = path.join(repoRoot, '.vscode-test');
const version = process.env.PLAYWRIGHT_VSCODE_VERSION ?? undefined;
const executablePath = await downloadAndUnzipVSCode({ version, cachePath });
await use(executablePath);
},
{ scope: 'worker' }
],
// Install VSIXs once per worker into a hash-keyed cache dir (VSIX mode only)
installedExtensionsDir: [
async ({ vscodeExecutable }, use): Promise<void> => {
if (!useVsix) {
await use(undefined);
return;
}
const repoRoot = resolveRepoRoot(fixturesDir);
const allDirs = [packageDir, 'salesforcedx-vscode-services', ...additionalExtensionDirs];
const vsixPaths = resolveVsixPaths(repoRoot, allDirs);
const cacheKey = await computeVsixCacheKey(vsixPaths);
const cacheDir = path.join(repoRoot, '.vscode-test', `ext-${cacheKey}`);
await installVsixsToCache(cacheDir, vsixPaths, vscodeExecutable);
await use(cacheDir);
},
{ scope: 'worker' }
],
// Create workspace directory (shared with electronApp so tests can access path)
workspaceDir: async ({}, use): Promise<void> => {
const dir = emptyWorkspace ? await createEmptyTestWorkspace() : await createTestWorkspace(orgAlias);
await use(dir);
},
// Launch fresh Electron instance per test
electronApp: async ({ vscodeExecutable, workspaceDir, installedExtensionsDir }, use): Promise<void> => {
// Use subdirectory of workspace for user data (keeps everything isolated and together)
const userDataDir = path.join(workspaceDir, '.vscode-test-user-data');
await fs.mkdir(userDataDir, { recursive: true });
const effectiveUserSettings = {
'files.simpleDialog.enable': true, // Use VS Code's simple dialog instead of native OS dialog (visible in Electron)
'settingsSync.enabled': false, // Prevent Settings Sync from overwriting test settings
'salesforcedx-vscode-salesforcedx.enableFileTraces': true,
// Avoid GitHub/Git prompts opening the system browser during local E2E (oauth, autofetch, etc.)
'github.gitAuthentication': false,
'git.terminalAuthentication': false,
'git.autofetch': false,
'git.openRepositoryInParentFolders': 'never',
// Suppress Copilot/Chat setup dialogs that trigger GitHub OAuth on startup
'chat.commandCenter.enabled': false,
'chat.setupFromDialog': false,
'workbench.startupEditor': 'none',
'workbench.enableExperiments': false,
'extensions.autoCheckUpdates': false,
'extensions.autoUpdate': false,
'telemetry.telemetryLevel': 'off',
'update.mode': 'none',
...userSettings
};
if (Object.keys(effectiveUserSettings).length > 0) {
const userSettingsDir = path.join(userDataDir, 'User');
await fs.mkdir(userSettingsDir, { recursive: true });
await fs.writeFile(path.join(userSettingsDir, 'settings.json'), JSON.stringify(effectiveUserSettings, null, 2));
}
const packageRoot = path.resolve(fixturesDir, '..', '..', '..');
const videosDir = path.join(packageRoot, 'test-results', 'videos');
await fs.mkdir(videosDir, { recursive: true });
// Explicitly disable built-in GitHub/Copilot/Chat extensions that can trigger
// the GitHub OAuth browser tab on startup. `--disable-extensions` only disables
// user-installed extensions; built-ins must be disabled individually.
const disabledBuiltins = [
'vscode.github',
'vscode.github-authentication',
'GitHub.vscode-pull-request-github',
'GitHub.copilot',
'GitHub.copilot-chat'
].map(id => `--disable-extension=${id}`);
let launchArgs: string[];
if (useVsix) {
// VSIX mode: extensions installed into hash-keyed cache dir; no dev path needed
launchArgs = [
`--user-data-dir=${userDataDir}`,
`--extensions-dir=${installedExtensionsDir}`,
...disabledBuiltins,
'--disable-workspace-trust',
'--no-sandbox',
workspaceDir
];
} else {
const extensionsDir = path.join(workspaceDir, '.vscode-test-extensions');
await fs.mkdir(extensionsDir, { recursive: true });
const extensionArgs = [
// Extension path is the package root (contains package.json and bundled dist/index.js)
packageRoot,
...additionalExtensionDirs
.concat(['salesforcedx-vscode-services'])
.map(dir => path.resolve(packageRoot, '..', dir))
].map(p => `--extensionDevelopmentPath=${p}`);
launchArgs = [
`--user-data-dir=${userDataDir}`,
`--extensions-dir=${extensionsDir}`,
...extensionArgs,
...(disableOtherExtensions ? ['--disable-extensions'] : []),
...disabledBuiltins,
'--disable-workspace-trust',
'--no-sandbox',
workspaceDir
];
}
const electronApp = await electron.launch({
executablePath: vscodeExecutable,
args: launchArgs,
env: { ...process.env, VSCODE_DESKTOP: '1' } as Record<string, string>,
timeout: 60_000,
recordVideo: {
dir: videosDir,
size: { width: 1920, height: 1080 }
}
});
try {
await use(electronApp);
} finally {
const proc = electronApp.process?.();
console.log(`[teardown] pid=${proc?.pid} platform=${process.platform} CI=${process.env.CI}`);
if (process.platform !== 'win32' && process.env.CI) {
// macOS/Linux CI: electronApp.close() hangs via CDP, leaving a dangling Promise
// that Playwright's worker teardown waits on (60s timeout). Kill the entire
// process group and destroy stdio pipes so the ChildProcess 'exit' event fires.
if (proc) {
forceKillProcessGroup(proc);
// Wait for Node.js to register the exit (pipes closed → 'close' event → 'exit' event)
await new Promise<void>(resolve => {
if (proc.exitCode !== null) {
resolve();
return;
}
proc.on('close', () => resolve());
setTimeout(resolve, 10_000);
});
}
console.log(`[teardown] exitCode=${proc?.exitCode} killed=${proc?.killed}`);
} else {
try {
await Promise.race([
electronApp.close(),
new Promise<false>(resolve => setTimeout(() => resolve(false), CLOSE_TIMEOUT_MS))
]);
} catch {}
// Force-kill if close didn't work (Windows timeout fallback)
if (proc?.exitCode === null && process.platform === 'win32') {
try {
process.kill(proc.pid!, 'SIGKILL');
} catch {}
}
}
console.log('[teardown] done');
}
},
// Get first window from Electron app
page: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow();
// Grant clipboard permissions for desktop (Electron)
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
// Capture console logs (especially errors) for debugging
page.on('console', msg => {
if (
msg.type() !== 'error' ||
filterErrors([{ text: msg.text(), url: msg.location()?.url || '' }]).length === 0
) {
return;
}
console.log(`[Electron Console Error] ${msg.text()}`);
// Also log the location if available
const { url, lineNumber } = msg.location() ?? {};
if (url) {
console.log(` at ${url}:${lineNumber}`);
}
});
// Electron ignores config's use.viewport — set explicitly for consistent sizing across CI runners
await page.setViewportSize({ width: 1920, height: 1080 });
const { WORKBENCH } = await import('../utils/locators.js');
await page.waitForSelector(WORKBENCH, { timeout: 60_000 });
await use(page);
}
});
test.afterEach(async ({ page }, testInfo) => {
// When hooks time out or the page fixture tears down early, `page` can be null — guard all access
if (!page) {
return;
}
if (process.env.DEBUG_MODE && testInfo.status !== 'passed') {
console.log('\n🔍 DEBUG_MODE: Test failed - pausing to keep VS Code window open.');
console.log('Press Resume in Playwright Inspector or close VS Code window to continue.');
await page.pause();
}
// Rename video with test name for easy identification
const video = page.video();
if (video) {
const videoPath = await video.path();
const safeName = testInfo.titlePath.join('-').replaceAll(/[^a-zA-Z0-9-]/g, '_');
const newPath = path.join(path.dirname(videoPath), `${safeName}.webm`);
await fs.rename(videoPath, newPath).catch(() => {});
}
});
return test;
};