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
1 change: 1 addition & 0 deletions apps/vscode-e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tsconfig.tsbuildinfo
10 changes: 7 additions & 3 deletions apps/vscode-e2e/base-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import {
VSCodeEvaluator,
cleanupMarkerFile,
} from './fixtures/vscode-evaluator';
import { getMarkerId, getWorkerDisplay } from './fixtures/vscode-e2e-runtime';
import {
MARKER_ARG_PREFIX,
getMarkerId,
getWorkerDisplay,
} from './fixtures/vscode-e2e-runtime';
import { NxConsolePage } from './page-objects/nx-console-page';

export interface LaunchOptions {
Expand Down Expand Up @@ -171,12 +175,11 @@ export const test = base.extend<
);

// Launch VS Code via Playwright's Electron support
const markerId = getMarkerId(workerInfo.workerIndex);
const markerId = getMarkerId(workerInfo.parallelIndex);
const env = { ...process.env };
cleanupMarkerFile(markerId);
// Critical: unset this when running from within VS Code/Claude Code
delete env.ELECTRON_RUN_AS_NODE;
env.VSCODE_E2E_MARKER_ID = markerId;
if (xvfb.display) {
env.DISPLAY = xvfb.display;
}
Expand All @@ -190,6 +193,7 @@ export const test = base.extend<
'--skip-welcome',
'--skip-release-notes',
'--disable-workspace-trust',
`${MARKER_ARG_PREFIX}${markerId}`,
`--extensionDevelopmentPath=${extensionDevelopmentPath}`,
`--extensionTestsPath=${extensionTestsPath}`,
`--extensions-dir=${extensionsDir}`,
Expand Down
35 changes: 35 additions & 0 deletions apps/vscode-e2e/fixtures/vscode-e2e-runtime.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
MARKER_ARG_PREFIX,
MARKER_ENV_VAR,
PLAYWRIGHT_PARALLEL_INDEX_ENV_VAR,
getCommandPaletteShortcut,
getMarkerFilePath,
getMarkerId,
getMarkerIdFromArgv,
getMarkerIdFromParallelIndexEnv,
getWorkerDisplay,
} from './vscode-e2e-runtime.ts';

Expand All @@ -18,6 +23,36 @@ test('marker ids and file paths are worker-specific', () => {
);
});

test('marker env var avoids the VS Code-reserved prefix', () => {
assert.equal(MARKER_ENV_VAR, 'NX_CONSOLE_E2E_MARKER_ID');
assert.equal(MARKER_ENV_VAR.startsWith('VSCODE_'), false);
});

test('marker ids can be derived from launch args', () => {
assert.equal(
getMarkerIdFromArgv([`${MARKER_ARG_PREFIX}worker-7`]),
'worker-7',
);
assert.equal(getMarkerIdFromArgv([]), undefined);
assert.equal(getMarkerIdFromArgv([MARKER_ARG_PREFIX]), undefined);
});

test('marker ids can be derived from the Playwright parallel index env', () => {
assert.equal(
getMarkerIdFromParallelIndexEnv({
[PLAYWRIGHT_PARALLEL_INDEX_ENV_VAR]: '2',
}),
'worker-2',
);
assert.equal(getMarkerIdFromParallelIndexEnv({}), undefined);
assert.equal(
getMarkerIdFromParallelIndexEnv({
[PLAYWRIGHT_PARALLEL_INDEX_ENV_VAR]: 'abc',
}),
undefined,
);
});

test('command palette shortcut matches host platform conventions', () => {
assert.strictEqual(getCommandPaletteShortcut('darwin'), 'Meta+Shift+P');
assert.strictEqual(getCommandPaletteShortcut('linux'), 'Control+Shift+P');
Expand Down
35 changes: 33 additions & 2 deletions apps/vscode-e2e/fixtures/vscode-e2e-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,40 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';

export const MARKER_DIR = join(tmpdir(), 'vscode-e2e-test-server');
export const MARKER_ENV_VAR = 'NX_CONSOLE_E2E_MARKER_ID';
export const PLAYWRIGHT_PARALLEL_INDEX_ENV_VAR = 'TEST_PARALLEL_INDEX';
export const MARKER_ARG_PREFIX = '--nx-console-e2e-marker-id=';

export function getMarkerId(workerIndex: number): string {
return `worker-${workerIndex}`;
export function getMarkerId(parallelIndex: number): string {
return `worker-${parallelIndex}`;
}

export function getMarkerIdFromParallelIndexEnv(
env: NodeJS.ProcessEnv,
): string | undefined {
const parallelIndexValue = env[PLAYWRIGHT_PARALLEL_INDEX_ENV_VAR];
if (parallelIndexValue === undefined) {
return undefined;
}

const parallelIndex = Number.parseInt(parallelIndexValue, 10);
if (!Number.isInteger(parallelIndex) || parallelIndex < 0) {
return undefined;
}

return getMarkerId(parallelIndex);
}

export function getMarkerIdFromArgv(
argv: readonly string[],
): string | undefined {
const markerArg = argv.find((arg) => arg.startsWith(MARKER_ARG_PREFIX));
if (!markerArg) {
return undefined;
}

const markerId = markerArg.slice(MARKER_ARG_PREFIX.length);
return markerId.length > 0 ? markerId : undefined;
}

export function getMarkerFilePath(markerId: string): string {
Expand Down
193 changes: 191 additions & 2 deletions apps/vscode-e2e/page-objects/nx-console-page.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Locator, Page } from '@playwright/test';
import { expect, type Frame, type Locator, type Page } from '@playwright/test';
import { VSCodeEvaluator } from '../fixtures/vscode-evaluator';
import { VSCodePage } from './vscode-page';

const EXTENSION_ID = 'nrwl.angular-console';
const GENERATE_UI_CONTEXT_MENU_LABEL = 'Nx Generate (UI)';

export class NxConsolePage extends VSCodePage {
constructor(page: Page, evaluator: VSCodeEvaluator) {
Expand All @@ -15,10 +16,26 @@ export class NxConsolePage extends VSCodePage {
.filter({ hasText: 'PROJECTS' });
}

get explorerSection(): Locator {
return this.page
.locator('.sidebar .split-view-view')
.filter({ hasText: 'EXPLORER' });
}

async openNxConsoleSidebar(): Promise<void> {
await this.activityBar.openTab('Nx Console');
}

async openExplorerSidebar(): Promise<void> {
await this.activityBar.openTab('Explorer');
}

async getWorkspaceName(): Promise<string> {
return this.evaluator.evaluate((vscodeApi) => {
return vscodeApi.workspace.workspaceFolders?.[0]?.name ?? '';
});
}

async waitForNxConsoleReady(timeout = 60_000): Promise<void> {
await this.waitForExtension(EXTENSION_ID, timeout);
await this.projectsSection
Expand All @@ -35,9 +52,15 @@ export class NxConsolePage extends VSCodePage {
return this.projectsSection.locator('.monaco-list');
}

getExplorerTreeItem(label: string): Locator {
return this.explorerSection.locator(
`.monaco-list-row[aria-label*="${label}"]`,
);
}

getProject(name: string): Locator {
return this.projectsSection.locator(
`.monaco-list-row[aria-label*="${name}"]`,
`.monaco-list-row[aria-label=${JSON.stringify(name)}]`,
);
}

Expand All @@ -60,4 +83,170 @@ export class NxConsolePage extends VSCodePage {
async openProjectDetails(projectName: string): Promise<void> {
await this.executeCommand('nx.project-details.openToSide', projectName);
}

async getGenerateUiTitle(): Promise<string | null> {
const frame = await this.getGenerateUiFrame();
return (await frame.locator('[data-cy="title"]').textContent())?.trim();
}

async getGenerateUiSubtitle(): Promise<string | null> {
const frame = await this.getGenerateUiFrame();
return (await frame.locator('[data-cy="subtitle"]').textContent())?.trim();
}

async getGenerateUiFieldValue(fieldName: string): Promise<string> {
const frame = await this.getGenerateUiFrame();
const field = frame.locator(`[id="${fieldName}-field"]`);
await field.waitFor({ state: 'visible', timeout: 30_000 });

return field.evaluate((element) => {
const value =
(element as { value?: string }).value ??
element.getAttribute('value') ??
'';
return `${value}`.trim();
});
}

async getGenerateUiBreadcrumbText(): Promise<string | null> {
const frame = await this.getGenerateUiFrame();
return (
await frame.locator('[data-cy="cwd-breadcrumb"]').textContent()
)?.trim();
}

async getGenerateUiBreadcrumbPath(): Promise<string> {
const frame = await this.getGenerateUiFrame();
const pathSegments = await frame
.locator('[data-cy^="cwd-breadcrumb-segment-"]')
.evaluateAll((elements) =>
elements
.map((element) => element.textContent?.trim() ?? '')
.filter((segment) => segment.length > 0),
);

return pathSegments.join('/');
}

async openGenerateUiFromProjectTreeItem(projectName: string): Promise<void> {
await this.openNxConsoleSidebar();

const projectItem = this.getProject(projectName);
await projectItem.waitFor({ state: 'visible', timeout: 30_000 });

await this.triggerContextMenuAction(
projectItem,
GENERATE_UI_CONTEXT_MENU_LABEL,
);
}

async openGenerateUiFromExplorerFile(
relativeFilePath: string,
): Promise<void> {
await this.openExplorerSidebar();
await this.revealFileInExplorer(relativeFilePath);

const fileName = relativeFilePath.split('/').at(-1);
if (!fileName) {
throw new Error(`Could not determine file name from ${relativeFilePath}`);
}

const fileItem = this.getExplorerTreeItem(fileName);
await fileItem.waitFor({ state: 'visible', timeout: 30_000 });

await this.triggerContextMenuAction(
fileItem,
GENERATE_UI_CONTEXT_MENU_LABEL,
);
}

private async getGenerateUiFrame(timeout = 30_000): Promise<Frame> {
let frameUrl: string | null = null;
await expect
.poll(
async () => {
frameUrl = null;

for (const frame of this.page.frames()) {
const title = frame.locator('[data-cy="title"]');

try {
if (
(await title.count()) > 0 &&
(await title.first().isVisible())
) {
frameUrl = frame.url();
return frameUrl;
}
} catch (error) {
if (
error instanceof Error &&
error.message.includes('Frame was detached')
) {
continue;
}

throw error;
}
}

return null;
},
{ timeout },
)
.not.toBeNull();

if (!frameUrl) {
throw new Error(`Generate UI webview did not load within ${timeout}ms`);
}

const activeFrame = this.page
.frames()
.find((frame) => frame.url() === frameUrl);
if (!activeFrame) {
throw new Error(
'Generate UI webview frame was found but is no longer available',
);
}

return activeFrame;
}

private async revealFileInExplorer(relativeFilePath: string): Promise<void> {
await this.evaluator.evaluate(async (vscodeApi, targetPath) => {
const workspaceFolder = vscodeApi.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
throw new Error('No workspace folder found');
}

const fileUri = `${targetPath as string}`
.split('/')
.filter(Boolean)
.reduce(
(uri, segment) => vscodeApi.Uri.joinPath(uri, segment),
workspaceFolder.uri,
);

await vscodeApi.commands.executeCommand('vscode.open', fileUri);
await vscodeApi.commands.executeCommand(
'workbench.files.action.showActiveFileInExplorer',
);
}, relativeFilePath);
}

private async triggerContextMenuAction(
item: Locator,
actionLabel: string,
): Promise<void> {
await item.scrollIntoViewIfNeeded();
await item.click({ button: 'right' });

const contextMenuItem = this.page
.locator('.context-view .action-item')
.filter({ hasText: actionLabel })
.first();

await contextMenuItem.waitFor({ state: 'visible', timeout: 10_000 });
await contextMenuItem.click();
}
}
9 changes: 8 additions & 1 deletion apps/vscode-e2e/runner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import * as http from 'node:http';
import * as fs from 'node:fs';
import * as vscode from 'vscode';
import {
MARKER_ENV_VAR,
MARKER_DIR,
getMarkerIdFromArgv,
getMarkerIdFromParallelIndexEnv,
getMarkerFilePath,
} from '../../fixtures/vscode-e2e-runtime';

Expand Down Expand Up @@ -58,7 +61,11 @@ export function run(): Promise<void> {
const address = server.address();
if (address && typeof address !== 'string') {
const url = `http://localhost:${address.port}`;
const markerId = process.env.VSCODE_E2E_MARKER_ID ?? `${process.pid}`;
const markerId =
getMarkerIdFromArgv(process.argv) ??
process.env[MARKER_ENV_VAR] ??
getMarkerIdFromParallelIndexEnv(process.env) ??
`${process.pid}`;
const markerFilePath = getMarkerFilePath(markerId);

fs.mkdirSync(MARKER_DIR, { recursive: true });
Expand Down
Loading
Loading