@vscjava/vscode-autotest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,886 @@
1
+ /**
2
+ * VscodeDriver — Core driver for launching and controlling VSCode via Playwright.
3
+ *
4
+ * Provides stable operation primitives categorized by reliability:
5
+ * - Level 1 (🟢): Command-based — uses VSCode command system, extremely stable
6
+ * - Level 2 (🟡): Role-based — uses Accessibility roles, stable across versions
7
+ * - Level 3 (🟠): Snapshot-based — AI reads A11y tree to decide, fully dynamic
8
+ */
9
+ import { _electron } from "@playwright/test";
10
+ import { downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } from "@vscode/test-electron";
11
+ import { execFileSync } from "node:child_process";
12
+ import * as fs from "node:fs";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+ const DEFAULT_TIMEOUT = 5000;
16
+ const COMMAND_PALETTE_KEY = "F1";
17
+ const ENTER_KEY = "Enter";
18
+ const CODE_ACTION_KEY = "Control+.";
19
+ const TRIGGER_SUGGEST_KEY = "Control+Space";
20
+ const NEXT_MARKER_COMMAND = "Go to Next Problem (Error, Warning, Info)";
21
+ const QUICK_INPUT_SELECTOR = ".quick-input-box input";
22
+ const QUICK_INPUT_WIDGET_SELECTOR = ".quick-input-widget";
23
+ const SUGGEST_WIDGET_SELECTOR = ".editor-widget.suggest-widget";
24
+ const WORKBENCH_SELECTOR = ".monaco-workbench";
25
+ export class VscodeDriver {
26
+ app = null;
27
+ page = null;
28
+ options;
29
+ /** Temp copy of workspace — cleaned up on close() */
30
+ tempWorkspaceDir = null;
31
+ constructor(options = {}) {
32
+ this.options = {
33
+ vscodeVersion: "insiders",
34
+ ...options,
35
+ };
36
+ }
37
+ // ═══════════════════════════════════════════════════════
38
+ // Lifecycle
39
+ // ═══════════════════════════════════════════════════════
40
+ async launch() {
41
+ // If a previous instance is still running, close it first
42
+ if (this.app) {
43
+ console.log("⚠️ Closing previous VSCode instance...");
44
+ await this.close();
45
+ }
46
+ const version = this.options.vscodeVersion ?? "insiders";
47
+ const vscodePath = await downloadAndUnzipVSCode(version);
48
+ const [cli, ...baseArgs] = resolveCliArgsFromVSCodeExecutablePath(vscodePath);
49
+ const userDataDir = this.options.userDataDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "autotest-"));
50
+ // Wipe user data dir to prevent window restoration and stale file index.
51
+ // Extensions are in a separate --extensions-dir so they won't be affected.
52
+ const defaultUserDataDir = baseArgs.find(a => a.startsWith("--user-data-dir="))?.split("=")[1];
53
+ if (defaultUserDataDir && fs.existsSync(defaultUserDataDir)) {
54
+ try {
55
+ fs.rmSync(defaultUserDataDir, { recursive: true, force: true });
56
+ }
57
+ catch {
58
+ // If locked by a previous process, wait and retry
59
+ await new Promise((r) => setTimeout(r, 2000));
60
+ try {
61
+ fs.rmSync(defaultUserDataDir, { recursive: true, force: true });
62
+ }
63
+ catch { /* proceed anyway */ }
64
+ }
65
+ }
66
+ // Pre-install marketplace extensions before launching
67
+ if (this.options.extensions && this.options.extensions.length > 0) {
68
+ console.log(`📦 Installing ${this.options.extensions.length} extension(s)...`);
69
+ for (const extId of this.options.extensions) {
70
+ console.log(` ↳ ${extId}`);
71
+ try {
72
+ execFileSync(cli, [
73
+ ...baseArgs,
74
+ "--install-extension", extId,
75
+ "--force",
76
+ ], {
77
+ stdio: "pipe",
78
+ timeout: 120_000,
79
+ env: { ...process.env },
80
+ shell: true, // Required on Windows for .cmd files
81
+ });
82
+ }
83
+ catch (e) {
84
+ console.warn(` ⚠️ Failed to install ${extId}: ${e.message}`);
85
+ }
86
+ }
87
+ console.log(`📦 Extensions installed\n`);
88
+ }
89
+ const args = [
90
+ "--no-sandbox",
91
+ "--disable-gpu-sandbox",
92
+ "--disable-updates",
93
+ "--skip-welcome",
94
+ "--skip-release-notes",
95
+ "--disable-workspace-trust",
96
+ "--password-store=basic",
97
+ ...baseArgs,
98
+ ];
99
+ if (this.options.extensionPath) {
100
+ args.push(`--extensionDevelopmentPath=${this.options.extensionPath}`);
101
+ }
102
+ if (this.options.workspacePath) {
103
+ // Use a fixed temp directory name so cleanup is deterministic
104
+ const tmpDir = os.tmpdir();
105
+ const fixedDir = path.join(tmpDir, "autotest-workspace");
106
+ // Remove any previous workspace copy and stale temp dirs
107
+ try {
108
+ for (const entry of fs.readdirSync(tmpDir)) {
109
+ if (entry.startsWith("autotest-ws-") || entry === "autotest-workspace") {
110
+ fs.rmSync(path.join(tmpDir, entry), { recursive: true, force: true });
111
+ }
112
+ }
113
+ }
114
+ catch { /* ignore */ }
115
+ this.tempWorkspaceDir = fixedDir;
116
+ fs.mkdirSync(fixedDir, { recursive: true });
117
+ const destDir = path.join(fixedDir, path.basename(this.options.workspacePath));
118
+ fs.cpSync(this.options.workspacePath, destDir, { recursive: true });
119
+ console.log(`📂 Workspace copied to: ${destDir}`);
120
+ args.push(destDir);
121
+ }
122
+ else if (this.options.filePath) {
123
+ // Single file mode — copy the file to a temp dir and open it directly
124
+ const tmpDir = os.tmpdir();
125
+ const fixedDir = path.join(tmpDir, "autotest-workspace");
126
+ if (fs.existsSync(fixedDir))
127
+ fs.rmSync(fixedDir, { recursive: true, force: true });
128
+ fs.mkdirSync(fixedDir, { recursive: true });
129
+ const destFile = path.join(fixedDir, path.basename(this.options.filePath));
130
+ fs.copyFileSync(this.options.filePath, destFile);
131
+ this.tempWorkspaceDir = fixedDir;
132
+ console.log(`📄 File copied to: ${destFile}`);
133
+ args.push(destFile);
134
+ }
135
+ // Inject settings.json into the ACTUAL user data dir that VSCode will use (from baseArgs)
136
+ const actualUserDataDir = baseArgs.find(a => a.startsWith("--user-data-dir="))?.split("=")[1] ?? userDataDir;
137
+ const settingsPath = path.join(actualUserDataDir, "User", "settings.json");
138
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
139
+ // Always disable window restoration for test isolation
140
+ const existingSettings = fs.existsSync(settingsPath)
141
+ ? JSON.parse(fs.readFileSync(settingsPath, "utf-8"))
142
+ : {};
143
+ const settings = {
144
+ ...existingSettings,
145
+ "window.restoreWindows": "none",
146
+ "window.newWindowDimensions": "maximized",
147
+ // Force Standard mode — Hybrid/LightWeight only provides syntax features
148
+ "java.server.launchMode": "Standard",
149
+ // Suppress notifications that can interfere with UI automation
150
+ "java.help.showReleaseNotes": false,
151
+ "java.help.firstView": "none",
152
+ "java.configuration.checkProjectSettingsExclusions": false,
153
+ "extensions.ignoreRecommendations": true,
154
+ "telemetry.telemetryLevel": "off",
155
+ "update.showReleaseNotes": false,
156
+ "workbench.enableExperiments": false,
157
+ "redhat.telemetry.enabled": false,
158
+ ...this.options.settings,
159
+ };
160
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
161
+ this.app = await _electron.launch({
162
+ executablePath: vscodePath,
163
+ env: { ...process.env, NODE_ENV: "development" },
164
+ args,
165
+ });
166
+ this.page = await this.app.firstWindow();
167
+ // Wait for VSCode workbench to render
168
+ await this.page.locator(WORKBENCH_SELECTOR).waitFor({ state: "visible", timeout: 30_000 });
169
+ // Dismiss all notification toasts that may interfere with UI automation
170
+ await this.dismissAllNotifications();
171
+ }
172
+ /** Close all visible notification toasts */
173
+ async dismissAllNotifications() {
174
+ try {
175
+ await this.runCommandFromPalette("Notifications: Clear All Notifications");
176
+ }
177
+ catch { /* ignore if command not found */ }
178
+ }
179
+ async close() {
180
+ if (this.app) {
181
+ await this.app.close();
182
+ this.app = null;
183
+ this.page = null;
184
+ }
185
+ // Clean up temp workspace copy (retry to handle file locks released after process exit)
186
+ if (this.tempWorkspaceDir) {
187
+ const dir = this.tempWorkspaceDir;
188
+ this.tempWorkspaceDir = null;
189
+ for (let attempt = 0; attempt < 5; attempt++) {
190
+ try {
191
+ fs.rmSync(dir, { recursive: true, force: true });
192
+ break;
193
+ }
194
+ catch {
195
+ await new Promise((r) => setTimeout(r, 1000));
196
+ }
197
+ }
198
+ }
199
+ }
200
+ getPage() {
201
+ if (!this.page)
202
+ throw new Error("VscodeDriver not launched. Call launch() first.");
203
+ return this.page;
204
+ }
205
+ // ═══════════════════════════════════════════════════════
206
+ // Level 1 🟢: Command-based operations (extremely stable)
207
+ // ═══════════════════════════════════════════════════════
208
+ /** Execute a VSCode command via Command Palette (F1 → type → Enter) */
209
+ async runCommandFromPalette(label) {
210
+ const page = this.getPage();
211
+ await page.keyboard.press(COMMAND_PALETTE_KEY);
212
+ const palette = page.locator(QUICK_INPUT_SELECTOR);
213
+ await palette.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
214
+ // F1 opens with ">" prefix for command mode — fill() replaces all text,
215
+ // so we must include ">" to stay in command search mode.
216
+ await palette.fill(`>${label}`);
217
+ await page.waitForTimeout(300);
218
+ await page.keyboard.press(ENTER_KEY);
219
+ // Wait for Quick Input to close
220
+ await page.locator(QUICK_INPUT_WIDGET_SELECTOR).waitFor({ state: "hidden", timeout: DEFAULT_TIMEOUT }).catch(() => { });
221
+ }
222
+ /** Open a file via Quick Open (Ctrl+P). Retries if the file indexer isn't ready. */
223
+ async openFile(filePath) {
224
+ const page = this.getPage();
225
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
226
+ const maxAttempts = 5;
227
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
228
+ await page.keyboard.press(`${modifier}+P`);
229
+ const input = page.locator(QUICK_INPUT_SELECTOR);
230
+ await input.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
231
+ await input.fill(filePath);
232
+ await page.waitForTimeout(500);
233
+ // Check if Quick Open found any results
234
+ const hasResults = await page.locator(".quick-input-list .monaco-list-row").count() > 0;
235
+ if (hasResults) {
236
+ await page.keyboard.press(ENTER_KEY);
237
+ await page.locator(QUICK_INPUT_WIDGET_SELECTOR).waitFor({ state: "hidden", timeout: DEFAULT_TIMEOUT }).catch(() => { });
238
+ return;
239
+ }
240
+ // No results — dismiss and retry after waiting for file indexer
241
+ await page.keyboard.press("Escape");
242
+ await page.locator(QUICK_INPUT_WIDGET_SELECTOR).waitFor({ state: "hidden", timeout: DEFAULT_TIMEOUT }).catch(() => { });
243
+ if (attempt < maxAttempts - 1) {
244
+ console.log(` ⏳ Quick Open: no results for "${filePath}", retrying (${attempt + 1}/${maxAttempts})...`);
245
+ await page.waitForTimeout(3000);
246
+ }
247
+ }
248
+ throw new Error(`File not found in Quick Open after ${maxAttempts} attempts: ${filePath}`);
249
+ }
250
+ /** Get the content of the active editor */
251
+ async getEditorContent() {
252
+ const page = this.getPage();
253
+ // Try Monaco model API first
254
+ const modelContent = await page.evaluate(() => {
255
+ const model = window.monaco?.editor?.getModels?.()?.[0];
256
+ return model?.getValue?.() ?? null;
257
+ });
258
+ if (modelContent)
259
+ return modelContent;
260
+ // Fallback: read visible text from editor DOM
261
+ return await page.locator(".monaco-editor .view-lines").first().innerText().catch(() => "");
262
+ }
263
+ /** Check if the active editor contains the specified text (checks model + visible DOM) */
264
+ async editorContains(text) {
265
+ const content = await this.getEditorContent();
266
+ if (content.includes(text))
267
+ return true;
268
+ // Fallback: use Playwright's getByText to search visible text in the editor
269
+ const page = this.getPage();
270
+ const found = await page.locator(".monaco-editor").getByText(text, { exact: false }).first()
271
+ .isVisible().catch(() => false);
272
+ return found;
273
+ }
274
+ /** Save the active file (Ctrl+S) */
275
+ async saveFile() {
276
+ const page = this.getPage();
277
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
278
+ await page.keyboard.press(`${modifier}+S`);
279
+ await page.waitForTimeout(500);
280
+ }
281
+ /** Go to a specific line number (Ctrl+G) */
282
+ async goToLine(line) {
283
+ const page = this.getPage();
284
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
285
+ await page.keyboard.press(`${modifier}+G`);
286
+ const input = page.locator(QUICK_INPUT_SELECTOR);
287
+ await input.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
288
+ // Ctrl+G opens with ":" prefix for line navigation — must preserve it
289
+ await input.fill(`:${line}`);
290
+ await page.keyboard.press(ENTER_KEY);
291
+ await page.locator(QUICK_INPUT_WIDGET_SELECTOR).waitFor({ state: "hidden", timeout: DEFAULT_TIMEOUT }).catch(() => { });
292
+ }
293
+ /** Move cursor to end of current line */
294
+ async goToEndOfLine() {
295
+ await this.getPage().keyboard.press("End");
296
+ }
297
+ /** Execute a keyboard shortcut */
298
+ async pressKeys(keys) {
299
+ const page = this.getPage();
300
+ await page.keyboard.press(keys);
301
+ await page.waitForTimeout(300);
302
+ }
303
+ /** Run a command in the integrated terminal */
304
+ async runInTerminal(command) {
305
+ await this.runCommandFromPalette("Terminal: Create New Terminal");
306
+ // Wait for terminal to be ready
307
+ const page = this.getPage();
308
+ await page.locator(".terminal-wrapper").first().waitFor({ state: "visible", timeout: 10_000 }).catch(() => { });
309
+ await page.keyboard.type(command);
310
+ await page.keyboard.press(ENTER_KEY);
311
+ await page.waitForTimeout(1000);
312
+ }
313
+ // ═══════════════════════════════════════════════════════
314
+ // Level 2 🟡: Role-based operations (stable)
315
+ // ═══════════════════════════════════════════════════════
316
+ /** Activate a side tab by name (e.g., "Explorer", "Extensions", "API Center") */
317
+ async activeSideTab(tabName) {
318
+ const page = this.getPage();
319
+ const tab = page.getByRole("tab", { name: tabName }).locator("a");
320
+ await tab.click();
321
+ // Wait for the corresponding side pane to render
322
+ await page.waitForTimeout(500);
323
+ }
324
+ /** Check if a side tab is visible */
325
+ async isSideTabVisible(tabName) {
326
+ const page = this.getPage();
327
+ return page.getByRole("tab", { name: tabName }).isVisible();
328
+ }
329
+ /** Click a tree item by its display name */
330
+ async clickTreeItem(name) {
331
+ const page = this.getPage();
332
+ const item = page.getByRole("treeitem", { name }).locator("a").first();
333
+ await item.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
334
+ await item.click();
335
+ await page.waitForTimeout(500);
336
+ }
337
+ /** Check if a tree item is visible */
338
+ async isTreeItemVisible(name) {
339
+ const page = this.getPage();
340
+ return page.getByRole("treeitem", { name }).isVisible();
341
+ }
342
+ /** Select an option by name in the Command Palette dropdown */
343
+ async selectPaletteOption(optionText) {
344
+ const page = this.getPage();
345
+ const option = page.getByRole("option", { name: optionText }).locator("a");
346
+ await option.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
347
+ await option.click();
348
+ await page.locator(QUICK_INPUT_WIDGET_SELECTOR).waitFor({ state: "hidden", timeout: DEFAULT_TIMEOUT }).catch(() => { });
349
+ }
350
+ /** Select an option by index in the Command Palette dropdown */
351
+ async selectPaletteOptionByIndex(index) {
352
+ const page = this.getPage();
353
+ const option = page.getByRole("option").nth(index).locator("a");
354
+ await option.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
355
+ await option.click();
356
+ await page.locator(QUICK_INPUT_WIDGET_SELECTOR).waitFor({ state: "hidden", timeout: DEFAULT_TIMEOUT }).catch(() => { });
357
+ }
358
+ /** Get all current notification messages */
359
+ async getNotifications() {
360
+ const page = this.getPage();
361
+ const notifications = await page.locator(".notifications-toasts .notification-toast").allTextContents();
362
+ return notifications;
363
+ }
364
+ /** Get status bar text */
365
+ async getStatusBarText() {
366
+ const page = this.getPage();
367
+ return await page.locator(".statusbar").textContent() ?? "";
368
+ }
369
+ // ═══════════════════════════════════════════════════════
370
+ // Level 3 🟠: Snapshot-based operations (AI dynamic)
371
+ // ═══════════════════════════════════════════════════════
372
+ /** Get the Accessibility tree snapshot of the current window */
373
+ async snapshot() {
374
+ const page = this.getPage();
375
+ // accessibility.snapshot() was removed in newer Playwright; use type assertion
376
+ const tree = await page.accessibility?.snapshot?.();
377
+ return tree ?? { role: "window", name: "empty" };
378
+ }
379
+ /** Get a DOM HTML snapshot */
380
+ async domSnapshot() {
381
+ const page = this.getPage();
382
+ return await page.evaluate(() => document.documentElement.outerHTML);
383
+ }
384
+ /** Take a screenshot and return as buffer */
385
+ async screenshot(outputPath) {
386
+ const page = this.getPage();
387
+ const buffer = await page.screenshot({ fullPage: true });
388
+ if (outputPath) {
389
+ fs.writeFileSync(outputPath, buffer);
390
+ }
391
+ return buffer;
392
+ }
393
+ /** Click any element by role and name (generic) */
394
+ async clickByRole(role, name) {
395
+ const page = this.getPage();
396
+ const el = page.getByRole(role, { name });
397
+ await el.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
398
+ await el.click();
399
+ }
400
+ /** Click any element containing specific text */
401
+ async clickByText(text) {
402
+ const page = this.getPage();
403
+ const el = page.getByText(text).first();
404
+ await el.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
405
+ await el.click();
406
+ }
407
+ // ═══════════════════════════════════════════════════════
408
+ // Level 1.5 🟢: Java / Language Server operations
409
+ // ═══════════════════════════════════════════════════════
410
+ /** Type text into the active editor at the cursor position */
411
+ async typeInEditor(text) {
412
+ const page = this.getPage();
413
+ // Dismiss any active suggest/autocomplete
414
+ await page.keyboard.press("Escape");
415
+ // Use VSCode's internal 'type' command — this is the same path as real keyboard input,
416
+ // so the Language Server will receive didChange notifications.
417
+ const success = await page.evaluate(async (t) => {
418
+ const vscode = window.require?.("vscode");
419
+ if (!vscode)
420
+ return false;
421
+ // 'type' command inserts text at cursor, triggers all editor events including LS sync
422
+ await vscode.commands.executeCommand("type", { text: t });
423
+ return true;
424
+ }, text);
425
+ if (!success) {
426
+ // Fallback: use Monaco executeEdits API
427
+ const editSuccess = await page.evaluate((t) => {
428
+ const editor = window.monaco?.editor?.getEditors?.()?.[0];
429
+ if (!editor)
430
+ return false;
431
+ const selection = editor.getSelection();
432
+ if (!selection)
433
+ return false;
434
+ const Range = window.monaco.Range;
435
+ editor.executeEdits("autotest", [{
436
+ range: new Range(selection.startLineNumber, selection.startColumn, selection.endLineNumber, selection.endColumn),
437
+ text: t,
438
+ forceMoveMarkers: true,
439
+ }]);
440
+ return true;
441
+ }, text);
442
+ if (!editSuccess) {
443
+ await page.keyboard.insertText(text);
444
+ }
445
+ }
446
+ await page.waitForTimeout(300);
447
+ await page.keyboard.press("Escape");
448
+ }
449
+ /** Select all text in the active editor */
450
+ async selectAllInEditor() {
451
+ const page = this.getPage();
452
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
453
+ await page.keyboard.press(`${modifier}+A`);
454
+ }
455
+ /** Replace entire editor content with new text */
456
+ async setEditorContent(content) {
457
+ await this.selectAllInEditor();
458
+ const page = this.getPage();
459
+ await page.keyboard.press("Delete");
460
+ await page.keyboard.type(content, { delay: 10 });
461
+ }
462
+ /**
463
+ * Type a snippet trigger word and select the snippet from completion list.
464
+ */
465
+ async typeAndTriggerSnippet(triggerWord) {
466
+ const page = this.getPage();
467
+ const editor = page.locator(".monaco-editor .view-lines").first();
468
+ await editor.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
469
+ await editor.click();
470
+ await page.keyboard.type(triggerWord, { delay: 50 });
471
+ // Trigger suggestion and wait for suggest widget
472
+ await page.keyboard.press(TRIGGER_SUGGEST_KEY);
473
+ await page.locator(SUGGEST_WIDGET_SELECTOR).waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT }).catch(() => { });
474
+ // Try to find and select the snippet item
475
+ const snippetOption = page.locator(".monaco-list-row .suggest-icon.codicon-symbol-snippet").first();
476
+ const hasSnippet = await snippetOption.isVisible().catch(() => false);
477
+ if (hasSnippet) {
478
+ await snippetOption.click();
479
+ }
480
+ else {
481
+ await page.keyboard.press(ENTER_KEY);
482
+ }
483
+ // Wait for suggest widget to close
484
+ await page.locator(SUGGEST_WIDGET_SELECTOR).waitFor({ state: "hidden", timeout: DEFAULT_TIMEOUT }).catch(() => { });
485
+ }
486
+ /**
487
+ * Wait for the Java Language Server to become ready.
488
+ * Polls the status bar for the LS ready indicator.
489
+ */
490
+ async waitForLanguageServer(timeoutMs = 120_000) {
491
+ const page = this.getPage();
492
+ const start = Date.now();
493
+ const pollInterval = 2000;
494
+ console.log(` ⏳ Waiting for Language Server (timeout: ${timeoutMs / 1000}s)...`);
495
+ let lastStatus = "";
496
+ while (Date.now() - start < timeoutMs) {
497
+ // Use Playwright locator to find the Java status bar item by its text
498
+ // The status text transitions: (none) → "Lightweight Mode" → "Activating" → "Importing" → "Ready"
499
+ const statusItems = page.locator("footer a, footer [role='button']");
500
+ const count = await statusItems.count();
501
+ let currentStatus = "";
502
+ for (let i = 0; i < count; i++) {
503
+ const text = (await statusItems.nth(i).textContent().catch(() => ""))?.trim() ?? "";
504
+ // Match "Java: Ready", "Java: Activating...", "Java: Importing Maven project(s)" etc.
505
+ if (/^Java:/.test(text) || /^☕/.test(text)) {
506
+ currentStatus = text;
507
+ break;
508
+ }
509
+ }
510
+ if (currentStatus && currentStatus !== lastStatus) {
511
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
512
+ console.log(` ⏳ ${elapsed}s — "${currentStatus}"`);
513
+ lastStatus = currentStatus;
514
+ }
515
+ // Only match "Java: Ready" exactly (or with 👍)
516
+ if (/Java:\s*Ready/i.test(currentStatus) || currentStatus.includes("👍")) {
517
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
518
+ console.log(` ✅ Language Server ready (${elapsed}s)`);
519
+ return true;
520
+ }
521
+ await page.waitForTimeout(pollInterval);
522
+ }
523
+ console.log(` ⚠️ Language Server not ready after ${timeoutMs / 1000}s (last: "${lastStatus}")`);
524
+ return false;
525
+ }
526
+ /**
527
+ * Get the count of errors and warnings in the Problems panel.
528
+ */
529
+ async getProblemsCount() {
530
+ const page = this.getPage();
531
+ // Strategy 1: read from status bar aria-labels (fast, ~5 elements)
532
+ const items = page.locator(".statusbar a");
533
+ const count = await items.count();
534
+ for (let i = 0; i < count; i++) {
535
+ const label = await items.nth(i).getAttribute("aria-label") ?? "";
536
+ const errMatch = label.match(/(\d+)\s*error/i);
537
+ const warnMatch = label.match(/(\d+)\s*warning/i);
538
+ if (errMatch || warnMatch) {
539
+ return {
540
+ errors: errMatch ? parseInt(errMatch[1], 10) : 0,
541
+ warnings: warnMatch ? parseInt(warnMatch[1], 10) : 0,
542
+ };
543
+ }
544
+ }
545
+ // Strategy 2: open Problems panel and count by icon class
546
+ await this.runCommandFromPalette("View: Focus Problems (Errors, Warnings, Infos)");
547
+ await page.waitForTimeout(500);
548
+ const errors = await page.locator(".markers-panel .codicon-error").count().catch(() => 0);
549
+ const warnings = await page.locator(".markers-panel .codicon-warning").count().catch(() => 0);
550
+ // Close the panel focus
551
+ await page.keyboard.press("Escape");
552
+ return { errors, warnings };
553
+ }
554
+ /** Navigate to the next problem (error/warning) in the editor */
555
+ async navigateToNextError() {
556
+ await this.runCommandFromPalette(NEXT_MARKER_COMMAND);
557
+ }
558
+ /** Navigate to a specific error by index (1-based) */
559
+ async navigateToError(index) {
560
+ for (let i = 0; i < index; i++) {
561
+ await this.runCommandFromPalette(NEXT_MARKER_COMMAND);
562
+ }
563
+ }
564
+ /**
565
+ * Trigger Code Action menu at current cursor and select an action by label.
566
+ */
567
+ async applyCodeAction(label) {
568
+ const page = this.getPage();
569
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
570
+ await page.keyboard.press(`${modifier}+.`);
571
+ // Wait for code action menu to appear
572
+ const actionItem = page.getByRole("option", { name: label }).first();
573
+ try {
574
+ await actionItem.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
575
+ await actionItem.click();
576
+ }
577
+ catch {
578
+ // Fallback: type in the filter and press Enter
579
+ const filterInput = page.locator(".context-view .monaco-inputbox input, .quick-input-box input").first();
580
+ const hasFilter = await filterInput.isVisible().catch(() => false);
581
+ if (hasFilter) {
582
+ await filterInput.fill(label);
583
+ await page.waitForTimeout(300);
584
+ }
585
+ await page.keyboard.press(ENTER_KEY);
586
+ }
587
+ // Wait for code action to be applied
588
+ await page.waitForTimeout(1000);
589
+ }
590
+ /**
591
+ * Trigger code completion (IntelliSense) at the current cursor position.
592
+ * Returns the visible completion items as an array of label strings.
593
+ */
594
+ async triggerCompletion() {
595
+ const page = this.getPage();
596
+ await page.keyboard.press(TRIGGER_SUGGEST_KEY);
597
+ // Wait for suggest widget to appear
598
+ await page.locator(SUGGEST_WIDGET_SELECTOR).waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT }).catch(() => { });
599
+ const items = await page.locator(".monaco-list-row .label-name").allTextContents().catch(() => []);
600
+ return items;
601
+ }
602
+ /** Dismiss the current completion widget */
603
+ async dismissCompletion() {
604
+ await this.getPage().keyboard.press("Escape");
605
+ await this.getPage().locator(SUGGEST_WIDGET_SELECTOR).waitFor({ state: "hidden", timeout: DEFAULT_TIMEOUT }).catch(() => { });
606
+ }
607
+ // ═══════════════════════════════════════════════════════
608
+ // Verification helpers
609
+ // ═══════════════════════════════════════════════════════
610
+ /** Check if an element with given role and name is visible */
611
+ async isElementVisible(role, name) {
612
+ const page = this.getPage();
613
+ return page.getByRole(role, { name }).isVisible();
614
+ }
615
+ /** Get text content of an element by role and name */
616
+ async getElementText(role, name) {
617
+ const page = this.getPage();
618
+ return (await page.getByRole(role, { name }).textContent()) ?? "";
619
+ }
620
+ /** Get Problems panel diagnostics */
621
+ async getProblems() {
622
+ const page = this.getPage();
623
+ const problems = await page.evaluate(() => {
624
+ // Try reading from VSCode's diagnostic API via DOM
625
+ const items = document.querySelectorAll(".markers-panel .monaco-list-row");
626
+ return Array.from(items).map((el) => ({
627
+ severity: "error",
628
+ message: el.textContent ?? "",
629
+ }));
630
+ });
631
+ return problems;
632
+ }
633
+ /** Check if a file exists in the workspace */
634
+ async fileExists(filePath) {
635
+ return fs.existsSync(filePath);
636
+ }
637
+ /** Check if a file contains specific text */
638
+ async fileContains(filePath, text) {
639
+ if (!fs.existsSync(filePath))
640
+ return false;
641
+ const content = fs.readFileSync(filePath, "utf-8");
642
+ return content.includes(text);
643
+ }
644
+ /** Read file content */
645
+ async readFile(filePath) {
646
+ return fs.readFileSync(filePath, "utf-8");
647
+ }
648
+ /** Get the workspace root path (temp copy) */
649
+ getWorkspacePath() {
650
+ if (!this.tempWorkspaceDir)
651
+ return null;
652
+ const entries = fs.readdirSync(this.tempWorkspaceDir);
653
+ return entries.length > 0 ? path.join(this.tempWorkspaceDir, entries[0]) : null;
654
+ }
655
+ /**
656
+ * Insert a line into a file on disk at the specified line number (1-based).
657
+ * The file must already be open in the editor. After modifying on disk,
658
+ * reverts the editor to pick up changes — the LS stays active and re-analyzes quickly.
659
+ */
660
+ async insertLineInFile(relativePath, lineNumber, text) {
661
+ const wsPath = this.getWorkspacePath();
662
+ if (!wsPath)
663
+ throw new Error("No workspace path available");
664
+ const filePath = path.join(wsPath, relativePath);
665
+ // Handle escaped \n sequences (literal backslash-n from YAML) as actual newlines.
666
+ // If text already contains real newlines (from YAML multiline), use as-is.
667
+ const resolvedText = text.includes("\\n") ? text.replace(/\\n/g, "\n") : text;
668
+ if (fs.existsSync(filePath)) {
669
+ // Modify existing file
670
+ const lines = fs.readFileSync(filePath, "utf-8").split("\n");
671
+ lines.splice(lineNumber - 1, 0, resolvedText);
672
+ fs.writeFileSync(filePath, lines.join("\n"));
673
+ console.log(` 📝 Inserted line ${lineNumber} in ${relativePath}`);
674
+ // Revert editor to pick up the on-disk changes
675
+ await this.runCommandFromPalette("File: Revert File");
676
+ }
677
+ else {
678
+ // Create new file with content
679
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
680
+ fs.writeFileSync(filePath, resolvedText);
681
+ console.log(` 📝 Created ${relativePath}`);
682
+ // Open the newly created file
683
+ await this.openFile(path.basename(filePath));
684
+ }
685
+ }
686
+ /** Revert the current file to its on-disk state */
687
+ async revertFile() {
688
+ await this.runCommandFromPalette("File: Revert File");
689
+ }
690
+ // ═══════════════════════════════════════════════════════
691
+ // Debugging operations
692
+ // ═══════════════════════════════════════════════════════
693
+ /** Start a debug session (F5 or via command) */
694
+ async startDebugSession() {
695
+ await this.getPage().keyboard.press("F5");
696
+ // Wait for debug toolbar to appear
697
+ await this.getPage().locator(".debug-toolbar").waitFor({ state: "visible", timeout: 30_000 }).catch(() => { });
698
+ }
699
+ /** Stop the current debug session (Shift+F5) */
700
+ async stopDebugSession() {
701
+ await this.getPage().keyboard.press("Shift+F5");
702
+ await this.getPage().locator(".debug-toolbar").waitFor({ state: "hidden", timeout: 10_000 }).catch(() => { });
703
+ }
704
+ /** Set a breakpoint at a specific line in the current file */
705
+ async setBreakpoint(line) {
706
+ await this.goToLine(line);
707
+ await this.runCommandFromPalette("Debug: Toggle Breakpoint");
708
+ }
709
+ /** Wait for the debugger to hit a breakpoint */
710
+ async waitForBreakpointHit(timeoutMs = 30_000) {
711
+ const page = this.getPage();
712
+ try {
713
+ // When paused, the debug toolbar shows pause-related buttons
714
+ // and the editor shows a yellow highlight on the stopped line
715
+ await page.locator(".debug-toolbar .codicon-debug-continue").waitFor({
716
+ state: "visible", timeout: timeoutMs,
717
+ });
718
+ return true;
719
+ }
720
+ catch {
721
+ return false;
722
+ }
723
+ }
724
+ /** Debug step over (F10) */
725
+ async debugStepOver() {
726
+ await this.getPage().keyboard.press("F10");
727
+ await this.getPage().waitForTimeout(500);
728
+ }
729
+ /** Debug step into (F11) */
730
+ async debugStepInto() {
731
+ await this.getPage().keyboard.press("F11");
732
+ await this.getPage().waitForTimeout(500);
733
+ }
734
+ /** Debug step out (Shift+F11) */
735
+ async debugStepOut() {
736
+ await this.getPage().keyboard.press("Shift+F11");
737
+ await this.getPage().waitForTimeout(500);
738
+ }
739
+ /** Get variable values from the Variables panel */
740
+ async getDebugVariables() {
741
+ const page = this.getPage();
742
+ // Focus on Variables view
743
+ await this.runCommandFromPalette("Debug: Focus on Variables View");
744
+ await page.waitForTimeout(500);
745
+ const items = await page.locator(".debug-view-content .monaco-list-row").all();
746
+ const variables = [];
747
+ for (const item of items) {
748
+ const text = await item.textContent().catch(() => "") ?? "";
749
+ // Format: "name: value" or "name = value"
750
+ const match = text.match(/^(.+?)[\s:=]+(.+)$/);
751
+ if (match) {
752
+ variables.push({ name: match[1].trim(), value: match[2].trim() });
753
+ }
754
+ }
755
+ return variables;
756
+ }
757
+ /** Get Debug Console output text */
758
+ async getDebugConsoleOutput() {
759
+ const page = this.getPage();
760
+ await this.runCommandFromPalette("Debug Console: Focus on Debug Console View");
761
+ await page.waitForTimeout(500);
762
+ const output = await page.locator(".repl .monaco-list-rows").textContent().catch(() => "");
763
+ return output ?? "";
764
+ }
765
+ // ═══════════════════════════════════════════════════════
766
+ // Test Runner operations
767
+ // ═══════════════════════════════════════════════════════
768
+ /** Open the Test Explorer view */
769
+ async openTestExplorer() {
770
+ await this.runCommandFromPalette("Testing: Focus on Test Explorer View");
771
+ }
772
+ /** Run all tests via command */
773
+ async runAllTests() {
774
+ await this.runCommandFromPalette("Test: Run All Tests");
775
+ }
776
+ /** Wait for test execution to complete by polling the test status bar */
777
+ async waitForTestComplete(timeoutMs = 60_000) {
778
+ const page = this.getPage();
779
+ const start = Date.now();
780
+ while (Date.now() - start < timeoutMs) {
781
+ // Check if test progress is done (no spinning icon in test explorer)
782
+ const spinning = await page.locator(".testing-progress-icon .codicon-loading").isVisible().catch(() => false);
783
+ if (!spinning && Date.now() - start > 3000) {
784
+ return true;
785
+ }
786
+ await page.waitForTimeout(2000);
787
+ }
788
+ return false;
789
+ }
790
+ /** Get test results summary from the Test Explorer */
791
+ async getTestResults() {
792
+ const page = this.getPage();
793
+ await this.openTestExplorer();
794
+ await page.waitForTimeout(500);
795
+ const passedCount = await page.locator(".test-explorer .codicon-testing-passed-icon").count().catch(() => 0);
796
+ const failedCount = await page.locator(".test-explorer .codicon-testing-failed-icon").count().catch(() => 0);
797
+ return {
798
+ passed: passedCount,
799
+ failed: failedCount,
800
+ total: passedCount + failedCount,
801
+ };
802
+ }
803
+ /** Click a CodeLens link by its text */
804
+ async clickCodeLens(label) {
805
+ const page = this.getPage();
806
+ const codeLens = page.locator(`.codelens-decoration a`).filter({ hasText: label }).first();
807
+ await codeLens.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
808
+ await codeLens.click();
809
+ await page.waitForTimeout(1000);
810
+ }
811
+ // ═══════════════════════════════════════════════════════
812
+ // Hover & context interaction
813
+ // ═══════════════════════════════════════════════════════
814
+ /** Hover on a symbol in the editor to trigger hover provider */
815
+ async hoverOnText(text) {
816
+ const page = this.getPage();
817
+ const target = page.locator(".monaco-editor .view-lines").getByText(text, { exact: false }).first();
818
+ await target.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
819
+ await target.hover();
820
+ // Wait for hover widget to appear
821
+ await page.locator(".monaco-hover").waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT }).catch(() => { });
822
+ }
823
+ /** Get the content of the hover popup */
824
+ async getHoverContent() {
825
+ const page = this.getPage();
826
+ return await page.locator(".monaco-hover-content").textContent().catch(() => "") ?? "";
827
+ }
828
+ /** Click an action link inside the hover popup */
829
+ async clickHoverAction(label) {
830
+ const page = this.getPage();
831
+ const action = page.locator(".monaco-hover-content a, .monaco-hover-content .action-label")
832
+ .filter({ hasText: label }).first();
833
+ await action.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
834
+ await action.click();
835
+ await page.waitForTimeout(500);
836
+ }
837
+ /** Dismiss the hover popup */
838
+ async dismissHover() {
839
+ await this.getPage().keyboard.press("Escape");
840
+ await this.getPage().locator(".monaco-hover").waitFor({ state: "hidden", timeout: DEFAULT_TIMEOUT }).catch(() => { });
841
+ }
842
+ // ═══════════════════════════════════════════════════════
843
+ // File Explorer context menu
844
+ // ═══════════════════════════════════════════════════════
845
+ /** Right-click a tree item and select a context menu option */
846
+ async contextMenuOnTreeItem(itemName, menuLabel) {
847
+ const page = this.getPage();
848
+ const item = page.getByRole("treeitem", { name: itemName }).locator("a");
849
+ await item.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
850
+ await item.click({ button: "right" });
851
+ // Wait for context menu
852
+ const menu = page.locator(".context-view .action-label").filter({ hasText: menuLabel }).first();
853
+ await menu.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
854
+ await menu.click();
855
+ await page.waitForTimeout(500);
856
+ }
857
+ /** Create a new file via Explorer right-click menu */
858
+ async createNewFileViaExplorer(parentFolder, fileName) {
859
+ await this.contextMenuOnTreeItem(parentFolder, "New File");
860
+ // Type the file name in the inline input
861
+ const page = this.getPage();
862
+ const input = page.locator(".explorer-viewlet .monaco-inputbox input").first();
863
+ await input.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT });
864
+ await input.fill(fileName);
865
+ await page.keyboard.press(ENTER_KEY);
866
+ await page.waitForTimeout(1000);
867
+ }
868
+ // ═══════════════════════════════════════════════════════
869
+ // Dependency tree operations
870
+ // ═══════════════════════════════════════════════════════
871
+ /** Open the Java Dependencies view */
872
+ async openDependencyExplorer() {
873
+ await this.runCommandFromPalette("Java: Focus on Java Dependencies View");
874
+ }
875
+ /** Expand a chain of tree nodes (e.g., ["Sources", "src", "main"]) */
876
+ async expandTreePath(names) {
877
+ for (const name of names) {
878
+ await this.clickTreeItem(name);
879
+ }
880
+ }
881
+ /** Wait for a specified duration (seconds) */
882
+ async wait(seconds) {
883
+ await this.getPage().waitForTimeout(seconds * 1000);
884
+ }
885
+ }
886
+ //# sourceMappingURL=vscodeDriver.js.map