Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
test_that("test_that with a
multi-line description passes", {
expect_equal(2 * 2, 4)
})
53 changes: 53 additions & 0 deletions extensions/positron-r/src/test/parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import './mocha-setup';

import * as assert from 'assert';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as vscode from 'vscode';
import { parseTestsFromFile } from '../testing/parser';
import { ItemType, TestingTools } from '../testing/util-testing';

suite('parseTestsFromFile', () => {
test('normalizes CRLF in a multi-line description to LF', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'r-test-parser-'));
const filePath = path.join(dir, 'test-crlf.R');
const source = [
'test_that("first line',
'second line", {',
' expect_true(TRUE)',
'})',
'',
].join('\r\n');
fs.writeFileSync(filePath, source);

const controller = vscode.tests.createTestController('test-parser', 'Test Parser');
const fileItem = controller.createTestItem('test-crlf.R', 'test-crlf.R', vscode.Uri.file(filePath));
const tools: TestingTools = {
packageRoot: vscode.Uri.file(dir),
packageName: 'testpkg',
controller,
testItemData: new WeakMap<vscode.TestItem, ItemType>(),
};

try {
await parseTestsFromFile(tools, fileItem);

const children: vscode.TestItem[] = [];
fileItem.children.forEach(child => children.push(child));

assert.deepStrictEqual(
children.map(child => ({ id: child.id, label: child.label })),
[{ id: 'test-crlf.R&first line\nsecond line', label: 'first line\nsecond line' }]
);
} finally {
controller.dispose();
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
22 changes: 22 additions & 0 deletions extensions/positron-r/src/test/util-testing.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import { escapeLabelForRDesc } from '../testing/util-testing';

suite('escapeLabelForRDesc', () => {
const cases: Record<string, [input: string, expected: string]> = {
'leaves a plain label untouched': ['plain label', 'plain label'],
'escapes single quotes': ['it\'s fine', 'it\\\'s fine'],
'escapes double quotes and backticks': ['a "b" `c`', 'a \\"b\\" \\`c\\`'],
'escapes a LF newline': ['multi\nline', 'multi\\nline'],
};

for (const [name, [input, expected]] of Object.entries(cases)) {
test(name, () => {
assert.strictEqual(escapeLabelForRDesc(input), expected);
});
}
});
7 changes: 5 additions & 2 deletions extensions/positron-r/src/testing/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,11 @@ function processCapture(
): Match {
return {
functionName: captureFunction.node.text,
// we start at 1 and end at (length - 1) because we don't want the surrounding quotes
desc: captureDesc.node.text.substring(1, captureDesc.node.text.length - 1),
// We start at 1 and end at (length - 1) because we don't want the surrounding
// quotes. We normalize CRLF/CR to LF so a multi-line description matches the desc
// testthat reports: it reads test files via brio::read_lines, which strips the CR.
// This keeps our test-item IDs and single-test `desc=` runs in sync on Windows (#10133).
desc: captureDesc.node.text.substring(1, captureDesc.node.text.length - 1).replace(/\r\n?/g, '\n'),
startPos: toVSCodePosition(captureCall.node.startPosition),
endPos: toVSCodePosition(captureCall.node.endPosition),
topLevel: captureCall.node.parent && captureCall.node.parent.type === 'program'
Expand Down
4 changes: 2 additions & 2 deletions extensions/positron-r/src/testing/runner-testthat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import split2 from 'split2';
import { LOGGER } from '../extension';
import { checkInstalled, getLocale } from '../session';
import { EXTENSION_ROOT_DIR } from '../constants';
import { ItemType, TestingTools, encodeNodeId } from './util-testing';
import { ItemType, TestingTools, encodeNodeId, escapeLabelForRDesc } from './util-testing';
import { TestResult } from './reporter';
import { parseTestsFromFile } from './parser';
import { RSessionManager } from '../session-manager';
Expand Down Expand Up @@ -93,7 +93,7 @@ export async function runThatTest(
testPath = testPath.replace(/\\/g, '/');

const devtoolsMethod = testType === ItemType.Directory ? 'test' : 'test_active_file';
const escapedLabel = test?.label.replace(/(['"`])/g, '\\$1');
const escapedLabel = test?.label === undefined ? undefined : escapeLabelForRDesc(test.label);
const descInsert = isSingleTest ? ` desc = '${escapedLabel || '<all tests>'}', ` : '';
const devtoolsCall =
`devtools::load_all('${testReporterPath}');` +
Expand Down
13 changes: 13 additions & 0 deletions extensions/positron-r/src/testing/util-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ export function encodeNodeId(
: testFile;
}

/**
* Escape a test label for use as the `desc` argument when running a single test
* via R. The label is embedded in an R single-quoted string literal, itself inside
* a double-quoted shell `-e` argument, so embedded quotes and backticks must be
* escaped. The `\n` inside a multi-line description must also be escaped for
* proper handling on Windows (#10133).
*/
export function escapeLabelForRDesc(label: string): string {
return label
.replace(/(['"`])/g, '\\$1')
.replace(/\n/g, '\\n');
}

export interface TestParser {
(testingTools: TestingTools, file: vscode.TestItem): Promise<void>;
}
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/infra/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export class Workbench {
this.terminal = new Terminal(code, this.quickaccess);
this.viewer = new Viewer(code, this.contextMenu);
this.editor = new Editor(code);
this.testExplorer = new TestExplorer(code);
this.testExplorer = new TestExplorer(code, this.quickaccess);
this.outline = new Outline(code, this.quickaccess);
this.extensions = new Extensions(code, this.quickaccess);
this.settings = new UserSettings(code, this.hotKeys);
Expand Down
29 changes: 25 additions & 4 deletions test/e2e/pages/testExplorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*--------------------------------------------------------------------------------------------*/

import { expect } from '@playwright/test';
import { Code } from '../infra/code';
import { QuickAccess } from './quickaccess';
import { Explorer } from './explorer';

const TEST_EXPLORER_ICON = '.composite-bar .codicon-test-view-icon';
Expand All @@ -13,11 +15,23 @@ const TEST_EXPLORER_ICON = '.composite-bar .codicon-test-view-icon';
*/
export class TestExplorer extends Explorer {

constructor(code: Code, private quickaccess: QuickAccess) {
super(code);
}

async openTestExplorer(): Promise<void> {
const locator = this.code.driver.currentPage.locator(TEST_EXPLORER_ICON);
await locator.waitFor({ state: 'attached' });
await locator.waitFor({ state: 'visible' });
await locator.click();
// The view container's activity-bar icon appears once test discovery has
// populated it; wait for that before focusing, or the command no-ops.
await this.code.driver.currentPage.locator(TEST_EXPLORER_ICON).waitFor({ state: 'visible' });
await this.quickaccess.runCommand('workbench.view.testing.focus');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching to a less janky method of focusing the test explorer, while we're here.

}

async collapseAllTests(): Promise<void> {
await this.quickaccess.runCommand('testing.collapseAll');
}

async clearAllTestResults(): Promise<void> {
await this.quickaccess.runCommand('testing.clearTestResults');
}

async expectTestItems(labels: string[]): Promise<void> {
Expand All @@ -31,6 +45,13 @@ export class TestExplorer extends Explorer {
await this.code.driver.currentPage.locator('.composite.title').getByLabel('Run Tests', { exact: true }).click();
}

async runTest(label: string): Promise<void> {
const tree = this.code.driver.currentPage.locator('.test-explorer');
const row = tree.locator('.monaco-list-row', { hasText: label });
await row.hover();
await row.getByLabel('Run Test', { exact: true }).click();
}

async expandAllTests(): Promise<void> {
const tree = this.code.driver.currentPage.locator('.test-explorer');
const collapsed = tree.locator('.monaco-list-row[aria-expanded="false"]');
Expand Down
26 changes: 21 additions & 5 deletions test/e2e/tests/test-explorer/test-explorer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,19 @@ test.describe('R Test Explorer', { tag: [tags.TEST_EXPLORER, tags.R_PKG_DEVELOPM
copyFixtureFolder(source, destination);
});

test('Basic R Test Explorer Functionality', async function ({ app, openFolder }) {
test.beforeEach(async function ({ app, openFolder }) {
const { testExplorer, sessions } = app.workbench;

// Open the test fixture folder
await openFolder(FIXTURE_NAME);

// Open the test explorer, start R session, and run tests
await testExplorer.openTestExplorer();
// Tests share one app instance; reset to a known state.
await testExplorer.collapseAllTests();
await testExplorer.clearAllTestResults();
Comment on lines +30 to +32

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beneficial once we go from 1 e2e to test to 2 and, before long, >2.

await sessions.start('r');
});

test('Basic R Test Explorer Functionality', async function ({ app }) {
const { testExplorer } = app.workbench;

await testExplorer.expectTestItems(['test-test-that.R', 'test-describe-it.R']);
await testExplorer.runAllTests();

Expand All @@ -53,4 +57,16 @@ test.describe('R Test Explorer', { tag: [tags.TEST_EXPLORER, tags.R_PKG_DEVELOPM
await testExplorer.expectTestStatus('test_that number 1 passes', 'Passed');
await testExplorer.expectTestStatus('test_that number 2 fails', 'Failed');
});

// https://github.com/posit-dev/positron/issues/10133
test('Test with multi-line description can be run by itself', async function ({ app }) {
const { testExplorer } = app.workbench;
const MULTI_LINE_LABEL = 'test_that with a multi-line description passes';

await testExplorer.expectTestItems(['test-multi-line-desc.R']);
await testExplorer.expandAllTests();

await testExplorer.runTest(MULTI_LINE_LABEL);
await testExplorer.expectTestStatus(MULTI_LINE_LABEL, 'Passed', 60000);
});
});
Loading