diff --git a/apps/vscode-e2e/.gitignore b/apps/vscode-e2e/.gitignore new file mode 100644 index 0000000000..cc1b7f1642 --- /dev/null +++ b/apps/vscode-e2e/.gitignore @@ -0,0 +1 @@ +tsconfig.tsbuildinfo diff --git a/apps/vscode-e2e/base-test.ts b/apps/vscode-e2e/base-test.ts index cf1d749845..ed620e96a6 100644 --- a/apps/vscode-e2e/base-test.ts +++ b/apps/vscode-e2e/base-test.ts @@ -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 { @@ -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; } @@ -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}`, diff --git a/apps/vscode-e2e/fixtures/vscode-e2e-runtime.spec.ts b/apps/vscode-e2e/fixtures/vscode-e2e-runtime.spec.ts index 96c31b866d..eefc86fc04 100644 --- a/apps/vscode-e2e/fixtures/vscode-e2e-runtime.spec.ts +++ b/apps/vscode-e2e/fixtures/vscode-e2e-runtime.spec.ts @@ -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'; @@ -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'); diff --git a/apps/vscode-e2e/fixtures/vscode-e2e-runtime.ts b/apps/vscode-e2e/fixtures/vscode-e2e-runtime.ts index 712fdb8e5d..615e39565d 100644 --- a/apps/vscode-e2e/fixtures/vscode-e2e-runtime.ts +++ b/apps/vscode-e2e/fixtures/vscode-e2e-runtime.ts @@ -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 { diff --git a/apps/vscode-e2e/page-objects/nx-console-page.ts b/apps/vscode-e2e/page-objects/nx-console-page.ts index 7d6e7ff3a8..00877dd89e 100644 --- a/apps/vscode-e2e/page-objects/nx-console-page.ts +++ b/apps/vscode-e2e/page-objects/nx-console-page.ts @@ -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) { @@ -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 { await this.activityBar.openTab('Nx Console'); } + async openExplorerSidebar(): Promise { + await this.activityBar.openTab('Explorer'); + } + + async getWorkspaceName(): Promise { + return this.evaluator.evaluate((vscodeApi) => { + return vscodeApi.workspace.workspaceFolders?.[0]?.name ?? ''; + }); + } + async waitForNxConsoleReady(timeout = 60_000): Promise { await this.waitForExtension(EXTENSION_ID, timeout); await this.projectsSection @@ -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)}]`, ); } @@ -60,4 +83,170 @@ export class NxConsolePage extends VSCodePage { async openProjectDetails(projectName: string): Promise { await this.executeCommand('nx.project-details.openToSide', projectName); } + + async getGenerateUiTitle(): Promise { + const frame = await this.getGenerateUiFrame(); + return (await frame.locator('[data-cy="title"]').textContent())?.trim(); + } + + async getGenerateUiSubtitle(): Promise { + const frame = await this.getGenerateUiFrame(); + return (await frame.locator('[data-cy="subtitle"]').textContent())?.trim(); + } + + async getGenerateUiFieldValue(fieldName: string): Promise { + 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 { + const frame = await this.getGenerateUiFrame(); + return ( + await frame.locator('[data-cy="cwd-breadcrumb"]').textContent() + )?.trim(); + } + + async getGenerateUiBreadcrumbPath(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } } diff --git a/apps/vscode-e2e/runner/src/index.ts b/apps/vscode-e2e/runner/src/index.ts index 20be878226..dad8f11857 100644 --- a/apps/vscode-e2e/runner/src/index.ts +++ b/apps/vscode-e2e/runner/src/index.ts @@ -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'; @@ -58,7 +61,11 @@ export function run(): Promise { 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 }); diff --git a/apps/vscode-e2e/specs/generate-ui-entry-points.test.ts b/apps/vscode-e2e/specs/generate-ui-entry-points.test.ts new file mode 100644 index 0000000000..24bd81a7fa --- /dev/null +++ b/apps/vscode-e2e/specs/generate-ui-entry-points.test.ts @@ -0,0 +1,110 @@ +import { posix } from 'node:path'; +import { test, expect } from '../base-test'; + +const GENERATE_UI_COMMAND_LABEL = 'Nx: Generate (UI)'; +const GENERATOR_LABEL = '@nx/react - component'; +const PROJECT_VIEW_PROJECT_NAME = 'e2e'; +const TARGET_FILE_PATH = 'src/main.tsx'; + +test('Generate UI opens from the command palette', async ({ nxConsole }) => { + const { workspaceName } = await getWorkspaceContext(nxConsole); + + await nxConsole.resetUI(); + await nxConsole.quickPick.execute(GENERATE_UI_COMMAND_LABEL); + await nxConsole.quickPick.selectItem(GENERATOR_LABEL); + + await expectGenerateUiToBeOpen(nxConsole); + await expect.poll(() => nxConsole.getGenerateUiTitle()).toBe('Component'); + await expect.poll(() => nxConsole.getGenerateUiSubtitle()).toBe('@nx/react'); + await expect + .poll(() => nxConsole.getGenerateUiFieldValue('project')) + .toBe(workspaceName); +}); + +test('Generate UI opens from the projects view project action', async ({ + nxConsole, +}) => { + const { workspaceName } = await getWorkspaceContext(nxConsole); + + expect(PROJECT_VIEW_PROJECT_NAME).not.toBe(workspaceName); + + await nxConsole.resetUI(); + await nxConsole.openGenerateUiFromProjectTreeItem(PROJECT_VIEW_PROJECT_NAME); + await nxConsole.quickPick.selectItem(GENERATOR_LABEL); + + await expectGenerateUiToBeOpen(nxConsole); + await expect + .poll(() => nxConsole.getGenerateUiFieldValue('project')) + .not.toBe(workspaceName); + await expect + .poll(() => nxConsole.getGenerateUiFieldValue('project')) + .toBe(PROJECT_VIEW_PROJECT_NAME); +}); + +test('Generate UI opens from the Explorer file context menu', async ({ + nxConsole, +}) => { + const { workspaceName } = await getWorkspaceContext(nxConsole); + const expectedBreadcrumbPath = posix.dirname(TARGET_FILE_PATH); + + expect(expectedBreadcrumbPath).not.toBe('.'); + + await nxConsole.resetUI(); + await nxConsole.openGenerateUiFromExplorerFile(TARGET_FILE_PATH); + await nxConsole.quickPick.selectItem(GENERATOR_LABEL); + + await expectGenerateUiToBeOpen(nxConsole); + await expect + .poll(() => nxConsole.getGenerateUiFieldValue('project')) + .toBe(workspaceName); + await expect + .poll(() => nxConsole.getGenerateUiBreadcrumbPath()) + .toBe(expectedBreadcrumbPath); +}); + +async function getWorkspaceContext(nxConsole: { + waitForNxConsoleReady: () => Promise; + evaluator: { + evaluate: ( + fn: ( + vscodeApi: typeof import('vscode'), + ...args: unknown[] + ) => T | Promise, + ...args: unknown[] + ) => Promise; + }; +}) { + await nxConsole.waitForNxConsoleReady(); + + const workspaceName = await nxConsole.evaluator.evaluate((vscodeApi) => { + return vscodeApi.workspace.workspaceFolders?.[0]?.name ?? ''; + }); + + expect(workspaceName).not.toBe(''); + + return { workspaceName }; +} + +async function expectGenerateUiToBeOpen(nxConsole: { + evaluator: { + evaluate: ( + fn: ( + vscodeApi: typeof import('vscode'), + ...args: unknown[] + ) => T | Promise, + ...args: unknown[] + ) => Promise; + }; +}) { + await expect + .poll( + () => + nxConsole.evaluator.evaluate((vscodeApi) => { + return vscodeApi.window.tabGroups.all.flatMap((group) => + group.tabs.map((tab) => tab.label), + ); + }), + { timeout: 30_000 }, + ) + .toContain('Generate UI'); +} diff --git a/apps/vscode-e2e/specs/smoke.test.ts b/apps/vscode-e2e/specs/smoke.test.ts index ba5f385d09..c9ce121a27 100644 --- a/apps/vscode-e2e/specs/smoke.test.ts +++ b/apps/vscode-e2e/specs/smoke.test.ts @@ -22,15 +22,15 @@ test('Nx Console smoke test', async ({ nxConsole }) => { // Open Nx Console sidebar and wait for projects to load await nxConsole.openNxConsoleSidebar(); - const projectsSection = nxConsole.page - .locator('.sidebar .split-view-view') - .filter({ hasText: 'PROJECTS' }); - const firstProject = projectsSection.locator('.monaco-list-row').first(); - await firstProject.waitFor({ state: 'visible', timeout: 90_000 }); + const workspaceName = await nxConsole.getWorkspaceName(); + expect(workspaceName).not.toBe(''); - // Expand the first project to see targets - await firstProject.click(); - await nxConsole.page.keyboard.press('ArrowRight'); + const projectsSection = nxConsole.projectsSection; + const workspaceProject = nxConsole.getProject(workspaceName); + await workspaceProject.waitFor({ state: 'visible', timeout: 90_000 }); + + // Expand the workspace project to see targets + await nxConsole.expandProject(workspaceName); await expect .poll(() => projectsSection.locator('.monaco-list-row').count(), { timeout: 10_000,