diff --git a/extensions/positron-r/src/test/README.md b/extensions/positron-r/src/test/README.md new file mode 100644 index 00000000000..dbc281f387b --- /dev/null +++ b/extensions/positron-r/src/test/README.md @@ -0,0 +1,5 @@ +Launch tests by running this from the repository root: + +```sh +yarn test-extension -l positron-r +``` diff --git a/extensions/positron-r/src/test/editor-utils.ts b/extensions/positron-r/src/test/editor-utils.ts new file mode 100644 index 00000000000..582449e0781 --- /dev/null +++ b/extensions/positron-r/src/test/editor-utils.ts @@ -0,0 +1,96 @@ +// From testUtils.ts in the typescript-language-feature extension +// https://github.com/posit-dev/positron/blob/main/extensions/typescript-language-features/src/test/testUtils.ts + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as os from 'os'; +import { join } from 'path'; +import * as vscode from 'vscode'; + +export function rndName() { + let name = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 10; i++) { + name += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return name; +} + +export function createRandomFile(contents = '', fileExtension = 'txt'): Thenable { + return new Promise((resolve, reject) => { + const tmpFile = join(os.tmpdir(), rndName() + '.' + fileExtension); + fs.writeFile(tmpFile, contents, (error) => { + if (error) { + return reject(error); + } + + resolve(vscode.Uri.file(tmpFile)); + }); + }); +} + +export function deleteFile(file: vscode.Uri): Thenable { + return new Promise((resolve, reject) => { + fs.unlink(file.fsPath, (err) => { + if (err) { + reject(err); + } else { + resolve(true); + } + }); + }); +} + +export const CURSOR = '"<>"'; + +export async function withFileEditor( + contents: string, + fileExtension: string, + run: (editor: vscode.TextEditor, doc: vscode.TextDocument) => Promise +): Promise { + const cursorIndex = contents.indexOf(CURSOR); + const rawContents = contents.replace(CURSOR, ''); + + const file = await createRandomFile(rawContents, fileExtension); + + try { + const doc = await vscode.workspace.openTextDocument(file); + const editor = await vscode.window.showTextDocument(doc); + + if (cursorIndex >= 0) { + const pos = doc.positionAt(cursorIndex); + editor.selection = new vscode.Selection(pos, pos); + } + + await run(editor, doc); + + if (doc.isDirty) { + await doc.save(); + } + } finally { + deleteFile(file); + } +} + +export const onDocumentChange = (doc: vscode.TextDocument): Promise => { + return new Promise(resolve => { + const sub = vscode.workspace.onDidChangeTextDocument(e => { + if (e.document !== doc) { + return; + } + sub.dispose(); + resolve(e.document); + }); + }); +}; + +export const type = async (document: vscode.TextDocument, text: string): Promise => { + const onChange = onDocumentChange(document); + await vscode.commands.executeCommand('type', { text }); + await onChange; + return document; +}; diff --git a/extensions/positron-r/src/test/indentation.test.ts b/extensions/positron-r/src/test/indentation.test.ts new file mode 100644 index 00000000000..ce1515c1710 --- /dev/null +++ b/extensions/positron-r/src/test/indentation.test.ts @@ -0,0 +1,66 @@ +import * as vscode from 'vscode'; +import * as assert from 'assert'; +import * as fs from 'fs'; +import { CURSOR, type, withFileEditor } from './editor-utils'; +import { EXTENSION_ROOT_DIR } from '../constants'; +import { removeLeadingLines } from '../util'; + +const snapshotsFolder = `${EXTENSION_ROOT_DIR}/src/test/snapshots`; +const snippetsPath = `${snapshotsFolder}/indentation-cases.R`; +const snapshotsPath = `${snapshotsFolder}/indentation-snapshots.R`; + +// FIXME: This should normally be run as part of tests setup in `before()` but +// it's somehow not defined +async function init() { + // Open workspace with custom configuration for snapshots. If you need + // custom settings set them there via `config.update()`. + const uri = vscode.Uri.file(snapshotsFolder); + await vscode.commands.executeCommand('vscode.openFolder', uri, false); + const config = vscode.workspace.getConfiguration(); + + // Prevents `ENOENT: no such file or directory` errors caused by us + // deleting temporary editor files befor Code had the opportunity to + // save the user history of these files. + config.update('workbench.localHistory.enabled', false, vscode.ConfigurationTarget.Workspace); +} + +suite('Indentation', () => { + // This regenerates snapshots in place. If the snapshots differ from last + // run, a failure is emitted. You can either commit the new output or discard + // it if that's a bug to fix. + test('Regenerate and check', async () => { + await init(); + const expected = fs.readFileSync(snapshotsPath, 'utf8'); + const current = await regenerateIndentSnapshots(); + + // Update snapshot file + fs.writeFileSync(snapshotsPath, current, 'utf8'); + + // Notify if snapshots were outdated + assert.strictEqual(expected, current); + }); +}); + +async function regenerateIndentSnapshots() { + const snippets = fs. + readFileSync(snippetsPath, 'utf8'). + split('# ---\n'); + + // Remove documentation snippet + snippets.splice(0, 1); + + const snapshots: string[] = ['# File generated from `indentation-cases.R`.\n\n']; + + for (const snippet of snippets) { + const bareSnippet = snippet.split('\n').slice(0, -1).join('\n'); + + await withFileEditor(snippet, 'R', async (_editor, doc) => { + // Type one newline character to trigger indentation + await type(doc, `\n${CURSOR}`); + const snapshot = removeLeadingLines(doc.getText(), /^$|^#/); + snapshots.push(bareSnippet + '\n# ->\n' + snapshot); + }); + } + + return snapshots.join('# ---\n'); +} diff --git a/extensions/positron-r/src/test/snapshots/.vscode/settings.json b/extensions/positron-r/src/test/snapshots/.vscode/settings.json new file mode 100644 index 00000000000..c755b1fd50a --- /dev/null +++ b/extensions/positron-r/src/test/snapshots/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "workbench.localHistory.enabled": false +} \ No newline at end of file diff --git a/extensions/positron-r/src/test/snapshots/indentation-cases.R b/extensions/positron-r/src/test/snapshots/indentation-cases.R new file mode 100644 index 00000000000..07c2f666fad --- /dev/null +++ b/extensions/positron-r/src/test/snapshots/indentation-cases.R @@ -0,0 +1,82 @@ +# Indentation snapshots. Edit cases in `indentation-cases.R` and observe results +# in `indentation-snapshots.R`. +# +# The cursor position is represented by a string containing angular brackets. A +# newline is typed at that position, triggering indentation rules. The result is +# saved in the snapshot file. +# +# Snippets are separated by `# ---`. This makes it possible to extract them and +# process them separately to prevent interferences between test cases. + +# --- +1 +"<>" + +# --- +1 + + 2 +"<>" + +# --- +data |>"<>" + +# --- +data |> + fn()"<>" + +# --- +# https://github.com/posit-dev/positron/issues/1727 +# FIXME +data |> + fn() +"<>" + +# --- +# https://github.com/posit-dev/positron/issues/1316 +data |> + fn() |>"<>" + +# --- +# With trailing whitespace +# https://github.com/posit-dev/positron/pull/1655#issuecomment-1780093395 +data |> + fn() |> "<>" + +# --- +data |> + fn1() |> + fn2() |>"<>" + +# --- +# FIXME +data |> + fn1() |> + fn2( + "arg" + )"<>" + +# --- +# https://github.com/posit-dev/positron-beta/discussions/46 +# FIXME +data |> + fn("<>") + +# --- +# FIXME +{ + fn(function() {}"<>") +} + +# --- +# FIXME +{ + fn(function() { + # + }"<>") +} + +# --- +for (i in NA) NULL"<>" + +# --- +# https://github.com/posit-dev/positron/issues/1880 +# FIXME +for (i in 1) fn()"<>" diff --git a/extensions/positron-r/src/test/snapshots/indentation-snapshots.R b/extensions/positron-r/src/test/snapshots/indentation-snapshots.R new file mode 100644 index 00000000000..035873c1316 --- /dev/null +++ b/extensions/positron-r/src/test/snapshots/indentation-snapshots.R @@ -0,0 +1,148 @@ +# File generated from `indentation-cases.R`. + +# --- +1 +"<>" + +# -> +1 + + "<>" + +# --- +1 + + 2 +"<>" + +# -> +1 + + 2 + + "<>" + +# --- +data |>"<>" + +# -> +data |> + "<>" + +# --- +data |> + fn()"<>" + +# -> +data |> + fn() +"<>" + +# --- +# https://github.com/posit-dev/positron/issues/1727 +# FIXME +data |> + fn() +"<>" + +# -> +data |> + fn() + + "<>" + +# --- +# https://github.com/posit-dev/positron/issues/1316 +data |> + fn() |>"<>" + +# -> +data |> + fn() |> + "<>" + +# --- +# With trailing whitespace +# https://github.com/posit-dev/positron/pull/1655#issuecomment-1780093395 +data |> + fn() |> "<>" + +# -> +data |> + fn() |> + "<>" + +# --- +data |> + fn1() |> + fn2() |>"<>" + +# -> +data |> + fn1() |> + fn2() |> + "<>" + +# --- +# FIXME +data |> + fn1() |> + fn2( + "arg" + )"<>" + +# -> +data |> + fn1() |> + fn2( + "arg" + ) + "<>" + +# --- +# https://github.com/posit-dev/positron-beta/discussions/46 +# FIXME +data |> + fn("<>") + +# -> +data |> + fn( +"<>") + +# --- +# FIXME +{ + fn(function() {}"<>") +} + +# -> +{ + fn(function() {} +"<>") +} + +# --- +# FIXME +{ + fn(function() { + # + }"<>") +} + +# -> +{ + fn(function() { + # + } +"<>") +} + +# --- +for (i in NA) NULL"<>" + +# -> +for (i in NA) NULL +"<>" + +# --- +# https://github.com/posit-dev/positron/issues/1880 +# FIXME +for (i in 1) fn()"<>" +# -> +for (i in 1) fn() + "<>" diff --git a/extensions/positron-r/src/util.ts b/extensions/positron-r/src/util.ts index cc86dd8a64d..5175de529c5 100644 --- a/extensions/positron-r/src/util.ts +++ b/extensions/positron-r/src/util.ts @@ -48,3 +48,18 @@ export function extractValue(str: string, key: string, delim: string = '='): str const m = str.match(re); return m?.[1] ?? ''; } + +export function removeLeadingLines(x: string, pattern: RegExp): string { + const lines = x.split('\n'); + let output = lines; + + for (const line of lines) { + if (pattern.test(line)) { + output = output.slice(1); + continue; + } + break; + } + + return output.join('\n'); +}