Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9c304de
e2e: add notebook release screenshots
midleman May 20, 2026
821509e
e2e: install Air extension if missing before verified-publisher scree…
midleman May 20, 2026
5fcf43b
e2e: include kernel icon in jupyter-notebooks-kernel-selector annotation
midleman May 20, 2026
ce74c83
e2e: shrink notebook viewport and add drop shadow halo
midleman May 20, 2026
5b293e3
e2e: round screenshot corners and override workspace label
midleman May 20, 2026
0f2d728
e2e: rewrite notebook kernel labels to (Venv: .venv) for screenshots
midleman May 20, 2026
baa06dc
e2e: bake drop-shadow halo into captureFullWindow
midleman May 20, 2026
42363be
e2e: move screenshot raw locators into POMs
midleman May 20, 2026
3d87eeb
e2e: add snippets release screenshots (4)
midleman May 20, 2026
c9b7659
e2e: add variables-pane + active-interpreter-session release screenshots
midleman May 20, 2026
9675fb0
e2e: fix snippets release screenshot selectors and triggers
midleman May 20, 2026
a16808a
e2e: scroll snippets picker with ArrowDown for cross-platform reliabi…
midleman May 21, 2026
87e860f
e2e: tune active-interpreter-session screenshot
midleman May 21, 2026
5974bcb
e2e: add r fixture to all snippets tests for stable session reuse
midleman May 21, 2026
219c0ad
e2e: POM-ify snippets release screenshots + bootstrap extensions in w…
midleman May 21, 2026
550dd2a
ci: SKIP_CLONE on the screenshot step
midleman May 21, 2026
09e58db
e2e: shrink active-interpreter-session viewport to 1280x800
midleman May 21, 2026
36488c0
e2e: move snippet annotation labels outside picker
midleman May 21, 2026
32559a2
e2e: shrink panel in variables-pane screenshot
midleman May 21, 2026
44e81a1
e2e: thinner borders, uniform badges, tighter keyword crop
midleman May 21, 2026
8115200
e2e: shrink auxiliary bar in interpreter session screenshots
midleman May 21, 2026
51bbe40
e2e: don't close aux bar before resizing it
midleman May 21, 2026
b81956e
e2e: thinner borders, wider aux bar, tighter snippets picker scroll
midleman May 21, 2026
62a7538
e2e: use pythonAlt for release screenshot sessions
midleman May 21, 2026
43d2c8f
e2e: make Python 3.13 the primary runtime for release screenshots
midleman May 22, 2026
4206a76
e2e: provision system Python alongside uv-managed primary
midleman May 22, 2026
a397e7e
e2e: cap release-screenshots failures at 2 for faster iteration
midleman May 22, 2026
6ee7965
e2e: add slack_notify input to release-screenshots workflow
midleman May 22, 2026
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
22 changes: 20 additions & 2 deletions .github/actions/setup-release-screenshot-runtimes/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ inputs:
python-primary-version:
description: "Python version request for setup-python (primary). Latest patch resolved."
required: false
default: '3.10'
default: '3.13'
python-alt-version:
description: "Python version request for setup-python (alternate). Latest patch resolved."
required: false
default: '3.13'
default: '3.10'
r-primary-version:
description: "R version installed via rig as the primary."
required: false
Expand Down Expand Up @@ -79,6 +79,24 @@ runs:
# requirements.
uv pip install astropy matplotlib

- name: Install Python dependencies (system Python)
shell: bash
run: |
# macos-latest-xlarge ships Python 3.13 pre-installed. Positron's
# runtime discovery prefers the system Python when it matches the
# POSITRON_PY_VER_SEL version, so we provision the system one
# with the same deps to make sure imports work regardless of
# which interpreter Positron selects.
SYS_PYTHON=$(command -v python${{ inputs.python-primary-version }} || true)
if [ -z "$SYS_PYTHON" ]; then
echo "::notice::No system python${{ inputs.python-primary-version }} on PATH; skipping system-Python provisioning"
exit 0
fi
echo "Provisioning system Python at: $SYS_PYTHON"
"$SYS_PYTHON" -m pip install --break-system-packages \
-r test/e2e/qa-example-content/requirements.txt \
ipykernel astropy matplotlib

- name: Install Python (alternate, no venv activation)
id: setup-python-alt
shell: bash
Expand Down
26 changes: 24 additions & 2 deletions .github/workflows/release-screenshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ on:
description: "Publish mode: open a PR if there are changes. No-op for nightly."
type: boolean
default: true
slack_notify:
description: "Notify #positron-test-results on nightly failure. Uncheck when triggering manually for testing."
type: boolean
default: true
schedule:
- cron: '0 9 * * 1-5' # 09:00 UTC, Mon-Fri (resolves to mode=nightly, version=latest-prerelease via 'auto')

Expand Down Expand Up @@ -128,6 +132,15 @@ jobs:
with:
identifier: release-screenshots-${{ env.MODE }}

# Wait for bundled extensions (quarto, air, etc.) to finish bootstrapping
# so language-aware screenshots have the full set of languages and
# completions available. Mirrors the bootstrap step in the e2e workflows.
- name: Wait for bootstrap extensions
env:
POSITRON_PY_VER_SEL: ${{ steps.runtimes.outputs.python-primary-version }}
POSITRON_R_VER_SEL: '4.5.2'
run: npx playwright test test/e2e/tests/extensions/bootstrap-extensions.test.ts --project e2e-electron --reporter=null

- name: Run release-screenshots project
env:
POSITRON_PY_VER_SEL: ${{ steps.runtimes.outputs.python-primary-version }}
Expand All @@ -144,7 +157,13 @@ jobs:
# aspect ratio as the historical 1680x1050 config but scaled down
# so text and chrome read proportionally larger in docs.
POSITRON_SCREENSHOT_VIEWPORT: '1512,945'
run: npx playwright test --project release-screenshots --workers 2
# Bootstrap already verified above; skip the bootstrap test inside
# the suite, and skip the workspace clone since globalSetup already
# populated it during the bootstrap run (re-running cp -R fails on
# the read-only .git/objects/pack files left behind).
SKIP_BOOTSTRAP: 'true'
SKIP_CLONE: 'true'
run: npx playwright test --project release-screenshots --workers 2 --max-failures=2

- name: Upload Playwright Report to S3
if: ${{ success() || failure() }}
Expand Down Expand Up @@ -342,7 +361,10 @@ jobs:
slack-notify:
# Nightly is on a schedule; failures should page #positron-test-results.
# Publish is on demand, so the operator already sees the result.
if: ${{ failure() && (inputs.mode || 'nightly') == 'nightly' }}
# `inputs.slack_notify` is unset on schedule events (cron), so the
# `!= false` check defaults to notifying; manual runs that uncheck the
# box opt out.
if: ${{ failure() && (inputs.mode || 'nightly') == 'nightly' && inputs.slack_notify != false }}
needs: [release-screenshots]
runs-on: ubuntu-latest
steps:
Expand Down
3 changes: 3 additions & 0 deletions test/e2e/infra/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { InlineDataExplorer } from '../pages/inlineDataExplorer.js';
import { InlineQuarto } from '../pages/inlineQuarto.js';
import { Publisher } from '../pages/publisher.js';
import { Packages } from '../pages/packages.js';
import { SuggestWidget } from '../pages/suggestWidget.js';

export interface Commands {
runCommand(command: string, options?: { exactLabelMatch?: boolean }): Promise<any>;
Expand Down Expand Up @@ -106,6 +107,7 @@ export class Workbench {
readonly inlineQuarto: InlineQuarto;
readonly publisher: Publisher;
readonly packages: Packages;
readonly suggestWidget: SuggestWidget;

constructor(code: Code) {
this.hotKeys = new HotKeys(code);
Expand Down Expand Up @@ -155,6 +157,7 @@ export class Workbench {
this.inlineQuarto = new InlineQuarto(code, this.quickaccess, this.hotKeys);
this.publisher = new Publisher(this.quickInput);
this.packages = new Packages(code, this.contextMenu, this.quickInput, this.toasts);
this.suggestWidget = new SuggestWidget(code);
}
}

Expand Down
20 changes: 19 additions & 1 deletion test/e2e/pages/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,23 @@ export class Extensions {
}
}


/**
* Install the extension currently shown in the extension editor (details
* pane) if it isn't already installed. No-op when the Uninstall button is
* present. Used by screenshot tests where we need a stable installed state
* regardless of whether the extension shipped pre-installed in the build.
*/
async installFromEditorIfNotInstalled(timeout = 60_000): Promise<void> {
const install = this.code.driver.currentPage
.locator('.extension-editor .monaco-action-bar .action-item:not(.disabled) .extension-action.install')
.first();
const uninstall = this.code.driver.currentPage
.locator('.extension-editor .monaco-action-bar .action-item:not(.disabled) .extension-action.uninstall')
.first();
if (!(await install.isVisible({ timeout: 2_000 }).catch(() => false))) {
return;
}
await install.click();
await expect(uninstall).toBeVisible({ timeout });
}
}
36 changes: 36 additions & 0 deletions test/e2e/pages/quickInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class QuickInput {
quickInput: Locator;
quickInputTitleBar: Locator;
quickInputResult: Locator;
widget: Locator;

constructor(private code: Code) {
this.quickInputList = this.code.driver.currentPage.locator(QUICK_INPUT_LIST);
Expand All @@ -31,6 +32,41 @@ export class QuickInput {
this.quickInputResult = this.code.driver.currentPage.locator(
QuickInput.QUICK_INPUT_RESULT,
);
this.widget = this.code.driver.currentPage.locator(QuickInput.QUICK_INPUT);
}

/**
* Locator for a quick-pick row whose aria-label starts with `prefix`.
* Monaco-list rows are virtualized, so aria-label is the most stable
* way to address a specific item without relying on DOM index.
*/
rowByAriaLabelPrefix(prefix: string): Locator {
return this.code.driver.currentPage.locator(
`${QUICK_INPUT_LIST} .monaco-list-row[aria-label^="${prefix}"]`,
);
}

/**
* Press ArrowDown until every passed row is rendered in the picker. Use
* when a screenshot needs alphabetical neighbors visible together (the
* virtualized window only renders ~20 rows, so simply scrolling to one
* target may leave neighbors below it un-rendered).
*
* Wheel-scroll the picker isn't reliable across platforms (no-ops in
* Linux CI), so we drive selection through the keyboard.
*/
async scrollIntoView(
rows: Locator[],
options?: { timeout?: number; intervalMs?: number },
): Promise<void> {
const timeout = options?.timeout ?? 60_000;
const intervalMs = options?.intervalMs ?? 50;
await expect(async () => {
await this.code.driver.currentPage.keyboard.press('ArrowDown');
for (const row of rows) {
await expect(row).toBeVisible({ timeout: 100 });
}
}).toPass({ timeout, intervals: [intervalMs] });
}

async expectTitleBarToHaveText(text: string): Promise<void> {
Expand Down
127 changes: 127 additions & 0 deletions test/e2e/pages/suggestWidget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import { expect, Locator } from '@playwright/test';
import { Code } from '../infra/code';

const WIDGET = '.suggest-widget.visible';
const FOCUSED_ROW = `${WIDGET} .monaco-list-row.focused`;
const SNIPPET_ICON = '.codicon-symbol-snippet';
// The details panel is overlay-positioned on document.body, not nested in
// the widget, so it has its own top-level selector.
const DETAILS_CONTAINER = '.suggest-details-container';

/**
* Monaco's editor suggest widget. Encapsulates trigger / toggle-details /
* focused-row navigation. Used by tests that need to drive completions
* deterministically (typing alone is racy across language extensions, and
* the details panel ships hidden by default in some configurations).
*/
export class SuggestWidget {
readonly widget: Locator;
readonly detailsContainer: Locator;
readonly focusedRow: Locator;

constructor(private code: Code) {
this.widget = this.code.driver.currentPage.locator(WIDGET);
this.detailsContainer = this.code.driver.currentPage.locator(DETAILS_CONTAINER);
this.focusedRow = this.code.driver.currentPage.locator(FOCUSED_ROW);
}

/**
* Locator for a row inside the visible widget whose textContent contains
* the given substring. Use as a Playwright locator (good for visibility
* waits); pair with {@link tagRow} when you need a stable CSS selector
* for `annotate()` (which uses plain querySelector).
*/
rowByText(text: string): Locator {
return this.widget.locator('.monaco-list-row', { hasText: text });
}

/**
* Trigger the suggest widget via `editor.action.triggerSuggest` (Ctrl+Space).
* Wrapped in toPass so a missed first press (focus not yet in editor) is
* retried. No-op once the widget is already visible.
*/
async trigger({ timeout = 15_000 }: { timeout?: number } = {}): Promise<void> {
await expect(async () => {
await this.code.driver.currentPage.keyboard.press('Control+Space');
await expect(this.widget).toBeVisible({ timeout: 3_000 });
}).toPass({ timeout });
}

/**
* Toggle the side details panel. `editor.action.toggleSuggestionDetails`
* shares the Ctrl+Space binding with triggerSuggest, disambiguated by
* widget visibility — so we only press it after `trigger()` has resolved.
*/
async toggleDetails({ timeout = 10_000 }: { timeout?: number } = {}): Promise<void> {
await expect(async () => {
await this.code.driver.currentPage.keyboard.press('Control+Space');
await expect(this.detailsContainer).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout });
}

/**
* Walk focus with ArrowDown until the focused row has a snippet icon.
* Keyword/identifier completions for the same prefix typically appear
* before the snippet entry, so a few presses land us on the snippet.
*/
async focusSnippetRow(maxSteps = 8): Promise<void> {
for (let i = 0; i < maxSteps; i++) {
const isSnippet = await this.focusedRow
.locator(SNIPPET_ICON)
.first()
.isVisible()
.catch(() => false);
if (isSnippet) {
return;
}
await this.code.driver.currentPage.keyboard.press('ArrowDown');
}
}

/**
* Add `data-screenshot-target="<id>"` to the first row whose textContent
* contains `match`. Lets {@link annotate} (which uses querySelector and
* doesn't understand Playwright's :has-text) find the row by a stable
* CSS attribute selector.
*/
async tagRow(match: string, id: string): Promise<void> {
await this.code.driver.currentPage.evaluate(
({ widget, match, id }) => {
const rows = document.querySelectorAll(`${widget} .monaco-list-row`);
for (const row of rows) {
if ((row.textContent ?? '').includes(match)) {
row.setAttribute('data-screenshot-target', id);
return;
}
}
},
{ widget: WIDGET, match, id },
);
}

/**
* Same as {@link tagRow} but matches against a regex applied to the row's
* textContent. Useful when the row identity needs whole-word matching
* (e.g. distinguishing `function` from `functionBody`).
*/
async tagRowByRegex(pattern: RegExp, id: string): Promise<void> {
await this.code.driver.currentPage.evaluate(
({ widget, source, flags, id }) => {
const re = new RegExp(source, flags);
const rows = document.querySelectorAll(`${widget} .monaco-list-row`);
for (const row of rows) {
if (re.test(row.textContent ?? '')) {
row.setAttribute('data-screenshot-target', id);
return;
}
}
},
{ widget: WIDGET, source: pattern.source, flags: pattern.flags, id },
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,18 @@ test.describe('Release Screenshots - Extension Publisher', () => {
test('Release Screenshot - extension-verified-publisher.png', async ({ app, page }) => {
const { extensions } = app.workbench;

// Air ships pre-installed and is published by Posit, so it
// reliably has the verified-publisher badge.
// Air is published by Posit, so it reliably has the verified-publisher badge.
const id = 'posit.air-vscode';
await extensions.openExtensionDetails(id);

const header = page.locator('.extension-editor .header');
await expect(header).toBeVisible();
await expect(header.locator('.publisher')).toBeVisible();

// Air ships pre-installed in some builds but not all; ensure installed
// so the screenshot always captures the Disable/Uninstall state.
await extensions.installFromEditorIfNotInstalled();

await hideToasts(app);

// Draw an orange rectangle around the verified-publisher widget.
Expand Down
Loading
Loading