/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; import { CHAT_MODEL } from '../../src/platform/configuration/common/configurationService'; import { TestingServiceCollection } from '../../src/platform/test/node/services'; import { escapeRegExpCharacters } from '../../src/util/vs/base/common/strings'; import { URI } from '../../src/util/vs/base/common/uri'; import { Configuration, ssuite, stest } from '../base/stest'; import { assertContainsAllSnippets, assertCriteriaMetAsync, assertFileContent, assertJSON, assertNoElidedCodeComments, getFileContent, getWorkspaceDiagnostics } from '../simulation/outcomeValidators'; import { EditTestStrategyPanel, simulatePanelCodeMapper } from '../simulation/panelCodeMapperSimulator'; import { assertInlineEdit, assertInlineEditShape, assertNoErrorOutcome, assertQualifiedFile, assertWorkspaceEdit, fromFixture, toFile } from '../simulation/stestUtil'; import { EditTestStrategy, IScenario } from '../simulation/types'; function executeEditTest( strategy: EditTestStrategyPanel, testingServiceCollection: TestingServiceCollection, scenario: IScenario ): Promise { return simulatePanelCodeMapper(testingServiceCollection, scenario, strategy); } function forEditsAndAgent(callback: (strategy: EditTestStrategyPanel, variant: string | undefined, model: string | undefined, configurations: Configuration[] | undefined) => void): void { callback(EditTestStrategy.Edits, '', undefined, undefined); callback(EditTestStrategy.Edits, '-claude', CHAT_MODEL.CLAUDE_SONNET, undefined); // callback(EditTestStrategy.Agent, '-agent', undefined); } forEditsAndAgent((strategy, variant, model, configurations) => { ssuite({ title: `multifile-edit${variant}`, location: 'panel', configurations }, () => { stest({ description: 'issue #8098: extract function to unseen file', language: 'typescript', model }, (testingServiceCollection) => { return executeEditTest(strategy, testingServiceCollection, { files: [ fromFixture('multiFileEdit/issue-8098/debugUtils.ts'), fromFixture('multiFileEdit/issue-8098/debugTelemetry.ts'), ], queries: [ { file: 'debugUtils.ts', selection: [34, 0, 34, 0], visibleRanges: [[3, 0, 44, 0]], query: 'Extract filterExceptionsFromTelemetry to debugTelemetry #file:debugUtils.ts', validate: async (outcome, workspace, accessor) => { assertWorkspaceEdit(outcome); assert.ok(outcome.files.length === 2, 'Expected two files to be edited'); const utilsTs = assertFileContent(outcome.files, 'debugUtils.ts'); assert.ok(!utilsTs.includes('function filterExceptionsFromTelemetry'), 'Expected filterExceptionsFromTelemetry to be extracted'); const telemetryFile = assertFileContent(outcome.files, 'debugTelemetry.ts'); assert.ok(telemetryFile.includes('filterExceptionsFromTelemetry'), 'Expected filterExceptionsFromTelemetry to be extracted'); assert.strictEqual((await getWorkspaceDiagnostics(accessor, workspace, 'tsc')).filter(d => d.kind === 'syntactic').length, 0); assertNoElidedCodeComments(outcome); } } ] }); }); stest({ description: 'issue #8131: properly using dotenv in this file', model }, (testingServiceCollection) => { return executeEditTest(strategy, testingServiceCollection, { files: [ fromFixture('multiFileEdit/issue-8131/extension.ts'), ], queries: [ { file: 'extension.ts', selection: [29, 20, 29, 20], visibleRanges: [[18, 0, 46, 0]], query: '#file:extension.ts Am I properly using dotenv in this file. The process.env.OPENAI_API_KEY keeps being undefined', validate: async (outcome, workspace, accessor) => { // TODO@add a good validation function here assert.fail('not implemented'); } } ] }); }); stest({ description: 'import new helper function', language: 'typescript', model }, (testingServiceCollection) => { return executeEditTest(strategy, testingServiceCollection, { files: [ fromFixture('multiFileEdit/fibonacci/version1.ts'), fromFixture('multiFileEdit/fibonacci/version2.ts'), fromFixture('multiFileEdit/fibonacci/foo.ts'), fromFixture('multiFileEdit/fibonacci/bar.ts'), ], queries: [ { file: 'foo.ts', selection: [0, 0, 4, 0], visibleRanges: [[9, 0, 4, 0]], query: 'Update #file:foo.ts and #file:bar.ts to use the fibonacci function from #file:version2.ts instead of #file:version1.ts', validate: async (outcome, workspace, accessor) => { assertWorkspaceEdit(outcome); assert.strictEqual((await getWorkspaceDiagnostics(accessor, workspace, 'tsc')).length, 0); assert.strictEqual(outcome.files.length, 2, 'Expected two files to be edited'); for (const file of outcome.files) { const content = getFileContent(file); assert.ok(content.includes('./version2'), 'Expected file to include updated import'); assert.ok(!content.includes('./version1'), 'Expected file to not include original import'); assertNoElidedCodeComments(content); } } } ] }); }); stest({ description: 'change library used by two files', language: 'typescript', model }, (testingServiceCollection) => { return executeEditTest(strategy, testingServiceCollection, { files: [ fromFixture('multiFileEdit/filepaths/1.ts'), fromFixture('multiFileEdit/filepaths/2.ts'), ], queries: [ { file: '1.ts', selection: [0, 0, 26, 0], visibleRanges: [[0, 0, 26, 0]], query: 'Update #file:1.ts and #file:2.ts to replace usage of "path" with vscode apis', validate: async (outcome, workspace, accessor) => { assertWorkspaceEdit(outcome); assert.strictEqual((await getWorkspaceDiagnostics(accessor, workspace, 'tsc')).filter(d => d.kind === 'syntactic').length, 0); assert.strictEqual(outcome.files.length, 2, 'Expected two files to be edited'); for (const file of outcome.files) { const content = getFileContent(file); assert.ok(!content.includes('path.join'), 'Expected file to not include path usage'); assert.ok(!content.includes('path.relative'), 'Expected file to not include path usage'); assertNoElidedCodeComments(content); } } } ] }); }); stest({ description: 'add validation logic to three files', language: 'typescript', model }, (testingServiceCollection) => { return executeEditTest(strategy, testingServiceCollection, { files: [ fromFixture('multiFileEdit/filepaths/1.ts'), fromFixture('multiFileEdit/filepaths/2.ts'), fromFixture('multiFileEdit/filepaths/3.ts'), ], queries: [ { file: '1.ts', selection: [0, 0, 26, 0], visibleRanges: [[0, 0, 26, 0]], query: 'Throw an error if we see a "http" uri #file:1.ts #file:2.ts #file:3.ts', validate: async (outcome, workspace, accessor) => { assertWorkspaceEdit(outcome); assert.strictEqual((await getWorkspaceDiagnostics(accessor, workspace, 'tsc')).filter(d => d.kind === 'syntactic').length, 0); assert.strictEqual(outcome.files.length, 3, 'Expected three files to be edited'); for (const file of outcome.files) { const content = getFileContent(file); assert.ok(content.includes('throw new Error'), 'Expected file to not include original import'); assertNoElidedCodeComments(content); } } } ] }); }); stest({ description: 'does not delete code (big file) #15475', language: 'typescript' }, (testingServiceCollection) => { return executeEditTest(strategy, testingServiceCollection, { files: [fromFixture('codeMapper/notebookEditorWidget.ts')], queries: [ { file: 'notebookEditorWidget.ts', selection: [497, 0, 501, 0], visibleRanges: [[480, 0, 520, 0]], query: 'add return types for getSelections', validate: async (outcome, workspace, accessor) => { assertInlineEdit(outcome); const edit = assertInlineEditShape(outcome, { line: 497, originalLength: 2957, modifiedLength: 2957, }); assertContainsAllSnippets(edit.changedModifiedLines.join('\n'), ['getSelections(): ICellRange[] {'], 'Edit not applied'); assert.deepStrictEqual( edit.changedModifiedLines.join('\n'), `getSelections(): ICellRange[] {`, 'Unrelated edits applied' ); assertNoElidedCodeComments(outcome.fileContents); } } ] }); }); stest({ description: 'add a command and dependency to a VS Code extension', language: 'typescript', model }, (testingServiceCollection) => { return executeEditTest(strategy, testingServiceCollection, { files: [ fromFixture('multiFileEdit/asciiart/package.json'), fromFixture('multiFileEdit/asciiart/src/extension.ts'), ], queries: [ { query: [ `In #file:extension.ts add a new command 'Hello ASCII World' which shows an information dialog that displays 'hello world' as ASCII art.`, `You can use the 'ascii-art' node module to generate the string.`, `Please also update #file:package.json with the new command and the new dependency.` ].join(' '), validate: async (outcome, workspace, accessor) => { assertWorkspaceEdit(outcome); assert.strictEqual((await getWorkspaceDiagnostics(accessor, workspace, 'tsc')).filter(d => d.kind === 'syntactic').length, 0); assert.strictEqual(outcome.files.length, 2, 'Expected two files to be edited'); const packageJson = assertFileContent(outcome.files, 'package.json'); const extensionTs = assertFileContent(outcome.files, 'extension.ts'); const packageJsonObj = assertJSON(packageJson); assert.ok(!packageJsonObj.devDependencies?.['ascii-art'], 'ascii-art dependency was added to devDependencies'); assert.ok(packageJsonObj.dependencies?.['ascii-art'], 'Expected package.json to include ascii-art dependency'); const commands = packageJsonObj.contributes.commands; assert.ok(Array.isArray(commands), 'Expected package.json to include a commands array'); assert.ok(commands.length === 2, 'Expected package.json to include a new command'); const newCommand = commands.find((c: { command: string }) => c.command !== 'test-multifile-1.helloWorld'); assert.ok(newCommand, 'Expected package.json to include a command other than helloWorld'); assert.ok(extensionTs.match(/\bimport\b[^;\n]+from ['"]ascii-art['"]/), 'Expected an import for ascii-art'); assert.ok(extensionTs.match(new RegExp(`\\bregisterCommand\\b[^;\n]['"]${escapeRegExpCharacters(newCommand.command)}['"]`)), 'expected that the new command is registered'); assertNoElidedCodeComments(outcome); } }, { query: [ `Better use figlet for creating the ascii art. Please remove the dependency on 'ascii-art'.`, ].join(' '), validate: async (outcome, workspace, accessor) => { assertWorkspaceEdit(outcome); assert.strictEqual((await getWorkspaceDiagnostics(accessor, workspace, 'tsc')).filter(d => d.kind === 'syntactic').length, 0); assert.strictEqual(outcome.files.length, 2, 'Expected two files to be edited'); const packageJson = assertFileContent(outcome.files, 'package.json'); const extensionTs = assertFileContent(outcome.files, 'extension.ts'); const packageJsonObj = assertJSON(packageJson); assert.ok(packageJsonObj.dependencies['figlet'], 'Expected package.json to include figlet dependency'); assert.ok(!packageJsonObj.dependencies['ascii-art'], 'Expected package.json no longer to contain the ascii-art dependency'); const commands = packageJsonObj.contributes.commands; assert.ok(Array.isArray(commands), 'Expected package.json to include a commands array'); assert.ok(commands.length === 2, 'Expected package.json to still include 2 command'); const newCommand = commands.find((c: { command: string }) => c.command !== 'test-multifile-1.helloWorld'); assert.ok(newCommand, 'Expected package.json to include a command other than helloWorld'); assert.ok(extensionTs.match(/\bimport\b[^;\n]+from ['"]figlet['"]/), 'Expected an import for figlet'); assertNoElidedCodeComments(outcome); } } ] }); }); stest.skip({ description: 'Issue #8336', model }, (testingServiceCollection) => { return executeEditTest(strategy, testingServiceCollection, { files: [ toFile({ fileName: 'roadmap-parser.ts', fileContents: `export interface ParseOptions {\n startLine?: string,\n endLine?: string\n};\n\nexport interface FilterOptions {\n markers?: string[],\n extractMatchingMarkers?: boolean\n};\n\nexport function filter(markdown: string, options?: ParseOptions & FilterOptions): string {\n return parse(markdown, options).filter(options).join();\n}\n\n\nexport class Marker {\n constructor(public label: string, public offset: number) { }\n\n public get length() {\n return this.label.length + 1;\n }\n}\n\nexport class Line {\n\n public static nonHeaderLevel(computedLevel: number): number {\n return computedLevel + 1000;\n }\n\n public static headerLevel(computedLevel: number) : number {\n return computedLevel;\n }\n\n\n public children: Line[] = [];\n public parent: Line | null = null;\n public isHeader: boolean = false;\n public level: number = Line.nonHeaderLevel(0);\n public markers: Marker[] = [];\n\n constructor(public markdown: string) {\n let count = -1;\n let iterator = {\n next: () => count === this.markdown.length ? undefined : this.markdown.charAt(++count),\n index: () => count\n }\n this.parse(iterator);\n };\n\n parse(i: { next: () => string | undefined, index: () => number }) {\n let c = i.next();\n\n // eat headers\n let hashes = 0;\n while ('#' === c) {\n hashes++;\n c = i.next();\n }\n if (hashes > 0) {\n this.isHeader = true;\n this.level = Line.headerLevel(hashes);\n return;\n }\n\n // eat spaces\n let spaces = 0;\n while (' ' === c) {\n spaces++;\n c = i.next();\n }\n\n if ('-' === c) {\n // isBullet === true, remember indentation\n this.level = Line.nonHeaderLevel(Math.floor(spaces / 3));\n c = i.next();\n }\n\n // skip spaces\n while (' ' === c) {\n c = i.next();\n }\n\n // skip check mark\n if ('[' === c) {\n c = i.next();\n while (']' === c || ' ' === c || 'x' === c || 'X' === c ) {\n c = i.next();\n }\n }\n\n // eat markers\n markers: while (':' === c) {\n const offset = i.index();\n const label = [];\n c = i.next();\n while (':' !== c) {\n label.push(c);\n c = i.next();\n if (c === undefined) {\n break markers;\n }\n }\n this.markers.push(new Marker(label.join(''), offset));\n c = i.next();\n while (' ' === c) {\n c = i.next();\n }\n }\n\n }\n\n add(line: Line): Line {\n this.children.push(line);\n return this;\n }\n\n matchesMarkers(markers: string[]): boolean {\n if (this.hasMarkers(markers)) {\n return true;\n }\n return this.children.some(c => c.matchesMarkers(markers));\n }\n\n hasMarkers(markers: string[]): boolean {\n return this.markers.filter(marker => markers.includes(marker.label)).length === markers.length;\n }\n\n sanitizedMarkdown(options: FilterOptions) {\n if (options.extractMatchingMarkers) {\n const markers = this.markers.sort((m1, m2) => m1.offset - m2.offset);\n let sanitized = '';\n let offset = 0;\n markers.forEach(m => {\n if (options.markers?.includes(m.label)) {\n let behindMarker = m.offset + m.length + 1;\n let c = this.markdown.charAt(behindMarker);\n while (c === ' ') {\n c = this.markdown.charAt(++behindMarker);\n }\n sanitized += this.markdown.substring(offset, behindMarker);\n offset = behindMarker;\n }\n });\n sanitized += this.markdown.substring(offset);\n return sanitized;\n }\n const markers = this.markers.sort((m1, m2) => m1.offset - m2.offset);\n let sanitized = '';\n let offset = 0;\n markers.forEach(m => {\n if (options.markers?.includes(m.label)) {\n let behindMarker = m.offset + m.length + 1;\n let c = this.markdown.charAt(behindMarker);\n while (c === ' ') {\n c = this.markdown.charAt(++behindMarker);\n }\n sanitized += this.markdown.substring(offset, m.offset);\n offset = behindMarker;\n }\n });\n sanitized += this.markdown.substring(offset);\n return sanitized;\n }\n return this.markdown;\n }\n\n isEmpty() : boolean {\n return this.markdown.length === 0;\n }\n}\n\nexport class LineTree {\n\n public lines: Line[] = []\n private lastAddition: Line | null = null;\n\n public add(line: Line) {\n if (this.lastAddition) {\n if (line.level > this.lastAddition.level) {\n line.parent = this.lastAddition;\n line.parent.add(line);\n } else if (line.level === this.lastAddition.level) {\n line.parent = this.lastAddition.parent;\n if (line.parent) {\n line.parent.add(line);\n } else {\n this.lines.push(line);\n }\n } else {\n let last: Line | null = this.lastAddition;\n while (last && line.level <= last.level) {\n last = last.parent;\n }\n if (last) {\n line.parent = last;\n line.parent.add(line);\n } else {\n this.lines.push(line);\n }\n }\n } else {\n this.lines.push(line);\n }\n this.lastAddition = line;\n }\n\n public filter(options?: FilterOptions): LineTree {\n if (options && options.markers) {\n const filteredTree = new LineTree();\n const filter = (lines: Line[]) => {\n lines.forEach(l => {\n if (l.matchesMarkers(options.markers!)) {\n filteredTree.add(l);\n }\n filter(l.children);\n });\n };\n filter(this.lines);\n return filteredTree;\n }\n return this;\n }\n\n public join(): string {\n const contents: string[] = [];\n const join = (lines: Line[]) => {\n lines.forEach(l => {\n contents.push(l.markdown);\n join(l.children);\n });\n };\n join(this.lines);\n return contents.join('\\n');\n }\n}\n\nexport function parse(markdown: string, options?: ParseOptions): LineTree {\n const tree = new LineTree();\n\n const input = markdown.split('\\n');\n let acceptLine = options?.startLine ? false : true;\n input.forEach(m => {\n if (options && options.startLine && options.endLine) {\n if (!acceptLine) {\n if (options.startLine === m.trim()) {\n acceptLine = true;\n }\n } else {\n if (options.endLine === m.trim()) {\n acceptLine = false;\n }\n }\n }\n if (acceptLine) {\n tree.add(new Line(m));\n }\n });\n\n return tree;\n}` }), toFile({ fileName: 'roadmap.ts', fileContents: 'import commandLineArgs from \'command-line-args\';\nimport { resolve, dirname } from \'path\'\nimport { readFileSync, writeFileSync, mkdirSync } from \'fs\';\nimport { filter } from \'./roadmap-parser\';\n\nconst optionDefinitions = [\n { name: \'scope\', type: String },\n { name: \'year\', type: String },\n { name: \'source\', type: String },\n { name: \'template\', type: String },\n { name: \'startLine\', type: String, defaultValue: \'\' },\n { name: \'endLine\', type: String, defaultValue: \'\' },\n { name: \'output\', type: String }\n];\n\ninterface Options {\n scope: string | undefined,\n year: string | undefined,\n source: string | undefined,\n template: string | undefined,\n startLine: string,\n endLine: string,\n output: string | undefined\n}\n\nconst options = commandLineArgs(optionDefinitions, { partial: true }) as Options\nif (!(options.source || options.year) || !options.source) {\n console.log(\n`\nroadmap {--scope } {--year <2021>} --source {--template