diff --git a/.vscode/settings.json b/.vscode/settings.json index ceca13d1..f84c5625 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,8 +10,8 @@ }, // VS Code Test Runner configuration - "testing.automaticallyOpenTestResults": "openOnTestStart", "testing.automaticallyOpenPeekView": "failureInVisibleDocument", + "testing.automaticallyOpenTestResults": "openOnTestStart", "testing.defaultGutterClickAction": "run", "testing.followRunningTest": true, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a9156837..9ef871d7 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -11,6 +11,9 @@ "group": { "kind": "build", "isDefault": true + }, + "presentation": { + "clear": true } } ] diff --git a/eslint.config.js b/eslint.config.js index 416dd638..be3ac261 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,6 +35,7 @@ module.exports = [ __dirname: "readonly", module: "readonly", setTimeout: "readonly", + console: "readonly", }, }, plugins: { diff --git a/package-lock.json b/package-lock.json index ba33bf8d..f20b7dee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2084,21 +2084,6 @@ "dev": true, "license": "ISC" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", diff --git a/src/powerquery-language-services/inspection/invokeExpression/invokeExpression.ts b/src/powerquery-language-services/inspection/invokeExpression/invokeExpression.ts index 5d9b77b0..05df093c 100644 --- a/src/powerquery-language-services/inspection/invokeExpression/invokeExpression.ts +++ b/src/powerquery-language-services/inspection/invokeExpression/invokeExpression.ts @@ -65,8 +65,6 @@ async function inspectInvokeExpression( correlationId, ); - settings.cancellationToken?.throwIfCancelled(); - const invokeExpressionXorNode: TXorNode | undefined = NodeIdMapUtils.xor(nodeIdMapCollection, invokeExpressionId); if (invokeExpressionXorNode === undefined) { @@ -93,6 +91,8 @@ async function inspectInvokeExpression( await tryType(settings, nodeIdMapCollection, previousNode.node.id, typeCache), ); + settings.cancellationToken?.throwIfCancelled(); + let invokeExpressionArgs: InvokeExpressionArguments | undefined; if (TypeUtils.isDefinedFunction(functionType)) { @@ -213,6 +213,7 @@ async function getArgumentTypes( for (const xorNode of argXorNodes) { // eslint-disable-next-line no-await-in-loop const triedArgType: TriedType = await tryType(settings, nodeIdMapCollection, xorNode.node.id, typeCache); + settings.cancellationToken?.throwIfCancelled(); if (ResultUtils.isError(triedArgType)) { throw triedArgType; diff --git a/src/powerquery-language-services/promiseUtils.ts b/src/powerquery-language-services/promiseUtils.ts new file mode 100644 index 00000000..7ae4c108 --- /dev/null +++ b/src/powerquery-language-services/promiseUtils.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ICancellationToken } from "@microsoft/powerquery-parser"; +import { setImmediate } from "timers"; + +/** + * Sequential processing with cancellation support - often better than Promise.all + * for cancellation scenarios because we can stop between each operation. + * Also yields control before each operation to allow cancellation tokens to take effect. + */ +export async function processSequentiallyWithCancellation( + items: T[], + processor: (item: T) => Promise, + cancellationToken?: ICancellationToken, +): Promise { + const results: R[] = []; + + for (const item of items) { + // Yield control to allow async cancellation tokens to take effect + // eslint-disable-next-line no-await-in-loop + await yieldForCancellation(cancellationToken); + + // eslint-disable-next-line no-await-in-loop + const result: R = await processor(item); + results.push(result); + } + + return results; +} + +export async function yieldForCancellation(cancellationToken?: ICancellationToken): Promise { + if (cancellationToken) { + // First yield to microtasks (handles synchronous cancellation) + await Promise.resolve(); + cancellationToken.throwIfCancelled(); + + // Additional yield for setImmediate-based cancellation tokens + // This ensures we yield to the timer queue where setImmediate callbacks execute + await new Promise((resolve: () => void) => setImmediate(resolve)); + cancellationToken.throwIfCancelled(); + } + + return Promise.resolve(); +} diff --git a/src/powerquery-language-services/validate/validate.ts b/src/powerquery-language-services/validate/validate.ts index 8906c80f..af0e2d3a 100644 --- a/src/powerquery-language-services/validate/validate.ts +++ b/src/powerquery-language-services/validate/validate.ts @@ -1,13 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { CommonError, Result, ResultUtils } from "@microsoft/powerquery-parser"; import { NodeIdMap, ParseError, ParseState } from "@microsoft/powerquery-parser/lib/powerquery-parser/parser"; import { Diagnostic } from "vscode-languageserver-types"; import { TextDocument } from "vscode-languageserver-textdocument"; import { Trace } from "@microsoft/powerquery-parser/lib/powerquery-parser/common/trace"; +import * as PromiseUtils from "../promiseUtils"; + import { Analysis, AnalysisSettings, AnalysisUtils } from "../analysis"; -import { CommonError, Result, ResultUtils } from "@microsoft/powerquery-parser"; import { TypeCache } from "../inspection"; import { validateDuplicateIdentifiers } from "./validateDuplicateIdentifiers"; import { validateFunctionExpression } from "./validateFunctionExpression"; @@ -35,7 +37,10 @@ export function validate( initialCorrelationId: trace.id, }; + validationSettings.cancellationToken?.throwIfCancelled(); + const analysis: Analysis = AnalysisUtils.analysis(textDocument, analysisSettings); + const parseState: ParseState | undefined = ResultUtils.assertOk(await analysis.getParseState()); const parseError: ParseError.ParseError | undefined = ResultUtils.assertOk(await analysis.getParseError()); @@ -58,49 +63,56 @@ export function validate( return result; } - let functionExpressionDiagnostics: Diagnostic[]; - let invokeExpressionDiagnostics: Diagnostic[]; - let unknownIdentifiersDiagnostics: Diagnostic[]; - const nodeIdMapCollection: NodeIdMap.Collection = parseState.contextState.nodeIdMapCollection; const typeCache: TypeCache = analysis.getTypeCache(); - if (validationSettings.checkInvokeExpressions && nodeIdMapCollection) { - functionExpressionDiagnostics = validateFunctionExpression(validationSettings, nodeIdMapCollection); + // Define validation operations to run sequentially + const validationOperations: (() => Promise)[] = [ + // Parse validation (if there are parse errors) + async (): Promise => await validateParse(parseError, updatedSettings), + ]; + + // Add conditional validations based on settings + if (validationSettings.checkForDuplicateIdentifiers && nodeIdMapCollection) { + validationOperations.push( + async (): Promise => + await validateDuplicateIdentifiers( + textDocument, + nodeIdMapCollection, + updatedSettings, + validationSettings.cancellationToken, + ), + ); + } - invokeExpressionDiagnostics = await validateInvokeExpression( - validationSettings, - nodeIdMapCollection, - typeCache, + if (validationSettings.checkInvokeExpressions && nodeIdMapCollection) { + validationOperations.push( + async (): Promise => + await validateFunctionExpression(validationSettings, nodeIdMapCollection), + async (): Promise => + await validateInvokeExpression(validationSettings, nodeIdMapCollection, typeCache), ); - } else { - functionExpressionDiagnostics = []; - invokeExpressionDiagnostics = []; } if (validationSettings.checkUnknownIdentifiers && nodeIdMapCollection) { - unknownIdentifiersDiagnostics = await validateUnknownIdentifiers( - validationSettings, - nodeIdMapCollection, - typeCache, + validationOperations.push( + async (): Promise => + await validateUnknownIdentifiers(validationSettings, nodeIdMapCollection, typeCache), ); - } else { - unknownIdentifiersDiagnostics = []; } + // Execute all validation operations sequentially with cancellation support + const allDiagnostics: Diagnostic[][] = await PromiseUtils.processSequentiallyWithCancellation( + validationOperations, + (operation: () => Promise) => operation(), + validationSettings.cancellationToken, + ); + + // Flatten all diagnostics into a single array + const flattenedDiagnostics: Diagnostic[] = allDiagnostics.flat(); + const result: ValidateOk = { - diagnostics: [ - ...validateDuplicateIdentifiers( - textDocument, - nodeIdMapCollection, - updatedSettings, - validationSettings.cancellationToken, - ), - ...(await validateParse(parseError, updatedSettings)), - ...functionExpressionDiagnostics, - ...invokeExpressionDiagnostics, - ...unknownIdentifiersDiagnostics, - ], + diagnostics: flattenedDiagnostics, hasSyntaxError: parseError !== undefined, }; diff --git a/src/powerquery-language-services/validate/validateDuplicateIdentifiers.ts b/src/powerquery-language-services/validate/validateDuplicateIdentifiers.ts index f04f5b5d..88781e01 100644 --- a/src/powerquery-language-services/validate/validateDuplicateIdentifiers.ts +++ b/src/powerquery-language-services/validate/validateDuplicateIdentifiers.ts @@ -14,18 +14,20 @@ import { ICancellationToken } from "@microsoft/powerquery-parser"; import { TextDocument } from "vscode-languageserver-textdocument"; import { Trace } from "@microsoft/powerquery-parser/lib/powerquery-parser/common/trace"; +import * as PromiseUtils from "../promiseUtils"; + import { Localization, LocalizationUtils } from "../localization"; import { DiagnosticErrorCode } from "../diagnosticErrorCode"; import { PositionUtils } from ".."; import { ValidationSettings } from "./validationSettings"; import { ValidationTraceConstant } from "../trace"; -export function validateDuplicateIdentifiers( +export async function validateDuplicateIdentifiers( textDocument: TextDocument, nodeIdMapCollection: NodeIdMap.Collection, validationSettings: ValidationSettings, cancellationToken: ICancellationToken | undefined, -): ReadonlyArray { +): Promise { const trace: Trace = validationSettings.traceManager.entry( ValidationTraceConstant.Validation, validateDuplicateIdentifiers.name, @@ -45,49 +47,64 @@ export function validateDuplicateIdentifiers( const documentUri: string = textDocument.uri; - const result: ReadonlyArray = [ - ...validateDuplicateIdentifiersForLetExpresion( - documentUri, - nodeIdMapCollection, - updatedSettings, - trace.id, - cancellationToken, - ), - ...validateDuplicateIdentifiersForRecord( - documentUri, - nodeIdMapCollection, - updatedSettings, - trace.id, - cancellationToken, - ), - ...validateDuplicateIdentifiersForRecordType( - documentUri, - nodeIdMapCollection, - updatedSettings, - trace.id, - cancellationToken, - ), - ...validateDuplicateIdentifiersForSection( - documentUri, - nodeIdMapCollection, - updatedSettings, - trace.id, - cancellationToken, - ), + // Create an array of validation functions to process sequentially + const validationFunctions: Array<() => Promise>> = [ + (): Promise> => + validateDuplicateIdentifiersForLetExpresion( + documentUri, + nodeIdMapCollection, + updatedSettings, + trace.id, + cancellationToken, + ), + (): Promise> => + validateDuplicateIdentifiersForRecord( + documentUri, + nodeIdMapCollection, + updatedSettings, + trace.id, + cancellationToken, + ), + (): Promise> => + validateDuplicateIdentifiersForRecordType( + documentUri, + nodeIdMapCollection, + updatedSettings, + trace.id, + cancellationToken, + ), + (): Promise> => + validateDuplicateIdentifiersForSection( + documentUri, + nodeIdMapCollection, + updatedSettings, + trace.id, + cancellationToken, + ), ]; + // Process all validation functions sequentially with cancellation support + const diagnosticArrays: ReadonlyArray[] = await PromiseUtils.processSequentiallyWithCancellation( + validationFunctions, + (validationFunction: () => Promise>) => validationFunction(), + cancellationToken, + ); + + // Flatten the results + const result: Diagnostic[] = diagnosticArrays.flat(); + trace.exit(); return result; } -function validateDuplicateIdentifiersForLetExpresion( +async function validateDuplicateIdentifiersForLetExpresion( documentUri: DocumentUri, nodeIdMapCollection: NodeIdMap.Collection, validationSettings: ValidationSettings, correlationId: number, cancellationToken: ICancellationToken | undefined, -): ReadonlyArray { +): Promise> { const trace: Trace = validationSettings.traceManager.entry( ValidationTraceConstant.Validation, validateDuplicateIdentifiersForLetExpresion.name, @@ -96,7 +113,7 @@ function validateDuplicateIdentifiersForLetExpresion( const letIds: Set = nodeIdMapCollection.idsByNodeKind.get(Ast.NodeKind.LetExpression) ?? new Set(); - const result: ReadonlyArray = validateDuplicateIdentifiersForKeyValuePair( + const result: ReadonlyArray = await validateDuplicateIdentifiersForKeyValuePair( documentUri, nodeIdMapCollection, [letIds], @@ -111,13 +128,13 @@ function validateDuplicateIdentifiersForLetExpresion( return result; } -function validateDuplicateIdentifiersForRecord( +async function validateDuplicateIdentifiersForRecord( documentUri: DocumentUri, nodeIdMapCollection: NodeIdMap.Collection, validationSettings: ValidationSettings, correlationId: number, cancellationToken: ICancellationToken | undefined, -): ReadonlyArray { +): Promise> { const trace: Trace = validationSettings.traceManager.entry( ValidationTraceConstant.Validation, validateDuplicateIdentifiersForRecord.name, @@ -129,7 +146,7 @@ function validateDuplicateIdentifiersForRecord( nodeIdMapCollection.idsByNodeKind.get(Ast.NodeKind.RecordLiteral) ?? new Set(), ]; - const result: ReadonlyArray = validateDuplicateIdentifiersForKeyValuePair( + const result: ReadonlyArray = await validateDuplicateIdentifiersForKeyValuePair( documentUri, nodeIdMapCollection, recordIds, @@ -144,13 +161,13 @@ function validateDuplicateIdentifiersForRecord( return result; } -function validateDuplicateIdentifiersForRecordType( +async function validateDuplicateIdentifiersForRecordType( documentUri: DocumentUri, nodeIdMapCollection: NodeIdMap.Collection, validationSettings: ValidationSettings, correlationId: number, cancellationToken: ICancellationToken | undefined, -): ReadonlyArray { +): Promise> { const trace: Trace = validationSettings.traceManager.entry( ValidationTraceConstant.Validation, validateDuplicateIdentifiersForRecordType.name, @@ -161,7 +178,7 @@ function validateDuplicateIdentifiersForRecordType( nodeIdMapCollection.idsByNodeKind.get(Ast.NodeKind.RecordType) ?? new Set(), ]; - const result: ReadonlyArray = validateDuplicateIdentifiersForKeyValuePair( + const result: ReadonlyArray = await validateDuplicateIdentifiersForKeyValuePair( documentUri, nodeIdMapCollection, recordTypeIds, @@ -176,13 +193,13 @@ function validateDuplicateIdentifiersForRecordType( return result; } -function validateDuplicateIdentifiersForSection( +async function validateDuplicateIdentifiersForSection( documentUri: DocumentUri, nodeIdMapCollection: NodeIdMap.Collection, validationSettings: ValidationSettings, correlationId: number, cancellationToken: ICancellationToken | undefined, -): ReadonlyArray { +): Promise> { const trace: Trace = validationSettings.traceManager.entry( ValidationTraceConstant.Validation, validateDuplicateIdentifiersForSection.name, @@ -191,7 +208,7 @@ function validateDuplicateIdentifiersForSection( const sectionIds: Set = nodeIdMapCollection.idsByNodeKind.get(Ast.NodeKind.Section) ?? new Set(); - const result: ReadonlyArray = validateDuplicateIdentifiersForKeyValuePair( + const result: ReadonlyArray = await validateDuplicateIdentifiersForKeyValuePair( documentUri, nodeIdMapCollection, [sectionIds], @@ -210,7 +227,7 @@ function validateDuplicateIdentifiersForSection( // for node in nodeIds: // for childOfNode in iterNodeFactory(node): // ... -function validateDuplicateIdentifiersForKeyValuePair( +async function validateDuplicateIdentifiersForKeyValuePair( documentUri: DocumentUri, nodeIdMapCollection: NodeIdMap.Collection, nodeIdCollections: ReadonlyArray>, @@ -221,7 +238,7 @@ function validateDuplicateIdentifiersForKeyValuePair( validationSettings: ValidationSettings, correlationId: number, cancellationToken: ICancellationToken | undefined, -): ReadonlyArray { +): Promise> { const numIds: number = nodeIdCollections.reduce( (partial: number, set: Set) => partial + set.size, 0, @@ -240,10 +257,11 @@ function validateDuplicateIdentifiersForKeyValuePair( const result: Diagnostic[] = []; + // If we need more cancellability, we can move this into the loop and yield every N iterations. + await PromiseUtils.yieldForCancellation(cancellationToken); + for (const collection of nodeIdCollections) { for (const nodeId of collection) { - cancellationToken?.throwIfCancelled(); - const node: TXorNode = NodeIdMapUtils.assertXor(nodeIdMapCollection, nodeId); const duplicateFieldsByKey: Map = new Map(); const knownFieldByKey: Map = new Map(); diff --git a/src/powerquery-language-services/validate/validateFunctionExpression.ts b/src/powerquery-language-services/validate/validateFunctionExpression.ts index cfab5de7..a27d5a18 100644 --- a/src/powerquery-language-services/validate/validateFunctionExpression.ts +++ b/src/powerquery-language-services/validate/validateFunctionExpression.ts @@ -12,6 +12,8 @@ import { Ast } from "@microsoft/powerquery-parser/lib/powerquery-parser/language import { Range } from "vscode-languageserver-textdocument"; import { Trace } from "@microsoft/powerquery-parser/lib/powerquery-parser/common/trace"; +import * as PromiseUtils from "../promiseUtils"; + import { Localization, LocalizationUtils } from "../localization"; import { DiagnosticErrorCode } from "../diagnosticErrorCode"; import { ILocalizationTemplates } from "../localization/templates"; @@ -20,10 +22,10 @@ import { ValidationSettings } from "./validationSettings"; import { ValidationTraceConstant } from "../trace"; // Check for repeat parameter names for FunctionExpressions. -export function validateFunctionExpression( +export async function validateFunctionExpression( validationSettings: ValidationSettings, nodeIdMapCollection: NodeIdMap.Collection, -): Diagnostic[] { +): Promise { const trace: Trace = validationSettings.traceManager.entry( ValidationTraceConstant.Validation, validateFunctionExpression.name, @@ -45,13 +47,26 @@ export function validateFunctionExpression( return []; } + // Yield control to allow for cancellation. + // If we need more cancellability, we can move this into the loop and yield every N iterations. + await PromiseUtils.yieldForCancellation(validationSettings.cancellationToken); + const diagnostics: Diagnostic[][] = []; for (const nodeId of fnExpressionIds) { - validationSettings.cancellationToken?.throwIfCancelled(); + const nodeDiagnostics: Diagnostic[] = validateNoDuplicateParameter( + updatedSettings, + nodeIdMapCollection, + nodeId, + ); + + diagnostics.push(nodeDiagnostics); - diagnostics.push(validateNoDuplicateParameter(updatedSettings, nodeIdMapCollection, nodeId)); - updatedSettings.cancellationToken?.throwIfCancelled(); + // Yield control periodically for better async behavior + // eslint-disable-next-line no-await-in-loop + await Promise.resolve(); + + validationSettings.cancellationToken?.throwIfCancelled(); } trace.exit(); @@ -73,8 +88,6 @@ function validateNoDuplicateParameter( const parameterNames: Map = new Map(); for (const parameter of NodeIdMapIterator.iterFunctionExpressionParameterNames(nodeIdMapCollection, fnExpression)) { - validationSettings.cancellationToken?.throwIfCancelled(); - const existingNames: Ast.Identifier[] = parameterNames.get(parameter.literal) ?? []; existingNames.push(parameter); parameterNames.set(parameter.literal, existingNames); diff --git a/src/powerquery-language-services/validate/validateInvokeExpression.ts b/src/powerquery-language-services/validate/validateInvokeExpression.ts index 37f254e9..17c29e13 100644 --- a/src/powerquery-language-services/validate/validateInvokeExpression.ts +++ b/src/powerquery-language-services/validate/validateInvokeExpression.ts @@ -8,6 +8,8 @@ import { NodeIdMap, NodeIdMapUtils, TXorNode } from "@microsoft/powerquery-parse import { Trace, TraceConstant } from "@microsoft/powerquery-parser/lib/powerquery-parser/common/trace"; import type { Range } from "vscode-languageserver-textdocument"; +import * as PromiseUtils from "../promiseUtils"; + import { Inspection, PositionUtils } from ".."; import { Localization, LocalizationUtils } from "../localization"; import { DiagnosticErrorCode } from "../diagnosticErrorCode"; @@ -44,18 +46,18 @@ export async function validateInvokeExpression( const inspectionTasks: Promise[] = []; for (const nodeId of invokeExpressionIds) { - validationSettings.cancellationToken?.throwIfCancelled(); - inspectionTasks.push(Inspection.tryInvokeExpression(updatedSettings, nodeIdMapCollection, nodeId, typeCache)); } - const inspections: ReadonlyArray = await Promise.all(inspectionTasks); + const inspections: Inspection.TriedInvokeExpression[] = await PromiseUtils.processSequentiallyWithCancellation( + inspectionTasks, + (task: Promise) => task, + validationSettings.cancellationToken, + ); const diagnosticTasks: Promise>[] = []; for (const triedInvokeExpression of inspections) { - validationSettings.cancellationToken?.throwIfCancelled(); - if (ResultUtils.isOk(triedInvokeExpression)) { diagnosticTasks.push( invokeExpressionToDiagnostics(updatedSettings, nodeIdMapCollection, triedInvokeExpression.value), @@ -67,7 +69,12 @@ export async function validateInvokeExpression( } } - const diagnostics: ReadonlyArray> = await Promise.all(diagnosticTasks); + const diagnostics: ReadonlyArray[] = await PromiseUtils.processSequentiallyWithCancellation( + diagnosticTasks, + (task: Promise>) => task, + validationSettings.cancellationToken, + ); + trace.exit(); return diagnostics.flat(); diff --git a/src/powerquery-language-services/validate/validationSettings/validationSettings.ts b/src/powerquery-language-services/validate/validationSettings/validationSettings.ts index b3ce67c9..989e9df6 100644 --- a/src/powerquery-language-services/validate/validationSettings/validationSettings.ts +++ b/src/powerquery-language-services/validate/validationSettings/validationSettings.ts @@ -1,13 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { ICancellationToken } from "@microsoft/powerquery-parser"; +import * as PQP from "@microsoft/powerquery-parser"; import { InspectionSettings } from "../../inspectionSettings"; import { Library } from "../../library"; export interface ValidationSettings extends InspectionSettings { - readonly cancellationToken: ICancellationToken | undefined; + readonly cancellationToken: PQP.ICancellationToken | undefined; readonly checkDiagnosticsOnParseError: boolean; readonly checkForDuplicateIdentifiers: boolean; readonly checkInvokeExpressions: boolean; diff --git a/src/test/files/LargeSectionDocument_WithDiagnostics.pq b/src/test/files/LargeSectionDocument_WithDiagnostics.pq new file mode 100644 index 00000000..46ccf2b5 --- /dev/null +++ b/src/test/files/LargeSectionDocument_WithDiagnostics.pq @@ -0,0 +1,359 @@ +[Version = "1.0.0"] +section LargeSectionDocument_WithDiagnostics; + +// This is a complex M section document designed to test validation performance +// It contains many unknown identifiers and other diagnostic errors but NO parsing errors +// Constants and basic values +BaseUrl = "https://api.example.com/v1/"; +MaxRetries = 5; +DefaultTimeout = 30; + +// Complex functions with unknown identifiers to generate diagnostic errors +shared ComplexFunction1 = (param1 as text, param2 as number, param3 as logical) as table => + let + Source = UnknownTableFunction(param1, param2), + Step1 = Table.AddColumn(Source, "NewCol", each UnknownScalarFunction([Column1], [Column2])), + Step2 = Table.TransformColumns(Step1, {{"NewCol", each UnknownTransformFunction(_)}}), + Step3 = Table.SelectRows(Step2, each UnknownFilterFunction([NewCol], param3)), + Result = Table.Sort(Step3, {{"NewCol", UnknownOrderFunction}}) + in + Result; + +shared ComplexFunction2 = (data as table, config as record) as table => + let + Column1 = Table.Column(data, UnknownColumnName1), + Column2 = Table.Column(data, UnknownColumnName2), + Processed1 = List.Transform(Column1, each UnknownProcessor1(_)), + Processed2 = List.Transform(Column2, each UnknownProcessor2(_)), + CombinedTable = #table({"Proc1", "Proc2"}, List.Zip({Processed1, Processed2})), + Aggregated = Table.Group(CombinedTable, {"Proc1"}, {{"Proc2Sum", each List.Sum([Proc2]), type number}}), + Final = Table.AddColumn(Aggregated, "Calculated", each UnknownCalculation([Proc1], [Proc2Sum])) + in + Final; + +shared ComplexFunction3 = (inputList as list, operations as list) as list => + let + ProcessOperation = (current as list, operation as record) as list => + let + OperationType = Record.Field(operation, "Type"), Parameter = Record.Field(operation, "Parameter") + in + if OperationType = "unknown1" then + List.Transform(current, each UnknownFunction1(_, Parameter)) + else if OperationType = "unknown2" then + List.Select(current, each UnknownFunction2(_, Parameter)) + else if OperationType = "unknown3" then + List.Sort(current, each UnknownFunction3(_, Parameter)) + else + current, + Result = List.Accumulate(operations, inputList, ProcessOperation) + in + Result; + +shared ComplexFunction4 = (textData as text, patterns as list) as record => + let + ExtractPattern = (text as text, pattern as record) as any => + let + PatternType = Record.Field(pattern, "Type"), PatternValue = Record.Field(pattern, "Value") + in + if PatternType = "unknown" then + UnknownExtractor(textData, PatternValue) + else if PatternType = "custom" then + CustomUnknownExtractor(textData, PatternValue) + else + null, + Results = List.Transform(patterns, each ExtractPattern(textData, _)), + Summary = [ + TotalMatches = List.Count(List.Select(Results, each _ <> null)), + FirstMatch = UnknownFirstFunction(Results), + LastMatch = UnknownLastFunction(Results), + ProcessedResults = List.Transform(Results, each UnknownResultProcessor(_)) + ] + in + Summary; + +shared ComplexFunction5 = (numericData as list, analysisType as text) as record => + let + BasicStats = [ + Sum = List.Sum(numericData), + Average = List.Average(numericData), + Count = List.Count(numericData) + ], + AdvancedStats = + if analysisType = "advanced" then + [ + StandardDev = UnknownStandardDeviation(numericData), + Variance = UnknownVariance(numericData), + Median = UnknownMedian(numericData), + Quartiles = UnknownQuartiles(numericData), + Outliers = UnknownOutlierDetection(numericData) + ] + else + [], + CombinedStats = BasicStats & AdvancedStats, + ProcessedStats = Record.TransformFields( + CombinedStats, + { + {"Sum", each UnknownSumProcessor(_)}, + {"Average", each UnknownAverageProcessor(_)}, + {"Count", each UnknownCountProcessor(_)} + } + ) + in + ProcessedStats; + +shared MachineLearningPipeline = (trainingData as table, features as list, target as text) as record => + let + // Data preprocessing with unknown functions + CleanedData = UnknownDataCleaner(trainingData), + FeatureMatrix = UnknownFeatureExtractor(CleanedData, features), + NormalizedFeatures = UnknownNormalizer(FeatureMatrix), + EncodedFeatures = UnknownCategoricalEncoder(NormalizedFeatures), + // Feature selection with unknown functions + SelectedFeatures = UnknownFeatureSelector(EncodedFeatures, target), + ImportanceScores = UnknownFeatureImportance(SelectedFeatures, target), + // Model training with unknown functions + TrainTestSplit = UnknownTrainTestSplitter(SelectedFeatures, 0.8), + TrainingSet = TrainTestSplit[Training], + TestingSet = TrainTestSplit[Testing], + Models = [ + LinearRegression = UnknownLinearRegression(TrainingSet, target), + RandomForest = UnknownRandomForest(TrainingSet, target), + XGBoost = UnknownXGBoost(TrainingSet, target), + NeuralNetwork = UnknownNeuralNetwork(TrainingSet, target), + SVM = UnknownSVM(TrainingSet, target) + ], + // Model evaluation with unknown functions + Predictions = Record.TransformFields( + Models, + { + "LinearRegression", + each UnknownPredict(_, TestingSet), + "RandomForest", + each UnknownPredict(_, TestingSet), + "XGBoost", + each UnknownPredict(_, TestingSet), + "NeuralNetwork", + each UnknownPredict(_, TestingSet), + "SVM", + each UnknownPredict(_, TestingSet) + } + ), + Metrics = Record.TransformFields( + Predictions, + { + "LinearRegression", + each UnknownEvaluateModel(_, TestingSet[target]), + "RandomForest", + each UnknownEvaluateModel(_, TestingSet[target]), + "XGBoost", + each UnknownEvaluateModel(_, TestingSet[target]), + "NeuralNetwork", + each UnknownEvaluateModel(_, TestingSet[target]), + "SVM", + each UnknownEvaluateModel(_, TestingSet[target]) + } + ), + BestModel = UnknownModelSelector(Metrics), + Hyperparameters = UnknownHyperparameterTuner(BestModel, TrainingSet), + FinalModel = UnknownModelTrainer(BestModel, Hyperparameters, TrainingSet) + in + [ + Data = [ + Original = trainingData, + Cleaned = CleanedData, + Features = SelectedFeatures, + Training = TrainingSet, + Testing = TestingSet + ], + Models = Models, + Evaluation = [ + Predictions = Predictions, + Metrics = Metrics, + BestModel = BestModel + ], + FinalModel = FinalModel, + FeatureImportance = ImportanceScores + ]; + +shared TimeSeriesAnalysis = (timeSeries as table, dateColumn as text, valueColumn as text) as record => + let + // Data preparation with unknown functions + SortedData = Table.Sort(timeSeries, dateColumn), + DateParsed = Table.TransformColumns(SortedData, {{dateColumn, DateTime.From}}), + // Time series decomposition with unknown functions + Trend = UnknownTrendAnalyzer(DateParsed, dateColumn, valueColumn), + Seasonality = UnknownSeasonalityDetector(DateParsed, dateColumn, valueColumn), + Residuals = UnknownResidualCalculator(DateParsed, Trend, Seasonality), + // Statistical analysis with unknown functions + Stationarity = UnknownStationarityTest(DateParsed, valueColumn), + AutoCorrelation = UnknownAutoCorrelationFunction(DateParsed, valueColumn), + PartialAutoCorrelation = UnknownPartialAutoCorrelationFunction(DateParsed, valueColumn), + // Anomaly detection with unknown functions + Outliers = UnknownOutlierDetector(DateParsed, valueColumn), + ChangePoints = UnknownChangePointDetector(DateParsed, valueColumn), + AnomalyScores = UnknownAnomalyScorer(DateParsed, valueColumn), + // Forecasting models with unknown functions + ArimaModel = UnknownArimaModel(DateParsed, valueColumn), + ExpSmoothingModel = UnknownExponentialSmoothingModel(DateParsed, valueColumn), + ProphetModel = UnknownProphetModel(DateParsed, dateColumn, valueColumn), + LstmModel = UnknownLSTMModel(DateParsed, valueColumn), + // Model comparison with unknown functions + ModelComparison = UnknownModelComparator({ArimaModel, ExpSmoothingModel, ProphetModel, LstmModel}), + BestForecastModel = UnknownBestModelSelector(ModelComparison), + // Forecasting with unknown functions + ForecastHorizon = 30, + Forecast = UnknownForecaster(BestForecastModel, ForecastHorizon), + ConfidenceIntervals = UnknownConfidenceIntervalCalculator(Forecast), + // Feature engineering with unknown functions + LagFeatures = UnknownLagFeatureGenerator(DateParsed, valueColumn, {1, 7, 30}), + RollingFeatures = UnknownRollingFeatureGenerator(DateParsed, valueColumn, {7, 14, 30}), + CalendarFeatures = UnknownCalendarFeatureGenerator(DateParsed, dateColumn) + in + [ + Data = [ + Original = timeSeries, + Processed = DateParsed, + Features = LagFeatures & RollingFeatures & CalendarFeatures + ], + Decomposition = [ + Trend = Trend, + Seasonality = Seasonality, + Residuals = Residuals + ], + Statistics = [ + Stationarity = Stationarity, + AutoCorrelation = AutoCorrelation, + PartialAutoCorrelation = PartialAutoCorrelation + ], + Anomalies = [ + Outliers = Outliers, + ChangePoints = ChangePoints, + AnomalyScores = AnomalyScores + ], + Models = [ + ARIMA = ArimaModel, + ExponentialSmoothing = ExpSmoothingModel, + Prophet = ProphetModel, + LSTM = LstmModel, + Comparison = ModelComparison, + Best = BestForecastModel + ], + Forecast = [ + Values = Forecast, + ConfidenceIntervals = ConfidenceIntervals, + Horizon = ForecastHorizon + ] + ]; + +shared NaturalLanguageProcessing = (textData as table, textColumn as text) as record => + let + // Text preprocessing with unknown functions + CleanedText = Table.TransformColumns(textData, {{textColumn, each UnknownTextCleaner(_)}}), + Tokenized = Table.TransformColumns(CleanedText, {{textColumn, each UnknownTokenizer(_)}}), + Normalized = Table.TransformColumns(Tokenized, {{textColumn, each UnknownTextNormalizer(_)}}), + // Language detection with unknown functions + Languages = Table.AddColumn( + Normalized, "Language", each UnknownLanguageDetector(Record.Field(_, textColumn)) + ), + // Linguistic analysis with unknown functions + POSTags = Table.AddColumn(Languages, "POSTags", each UnknownPOSTagger(Record.Field(_, textColumn))), + NamedEntities = Table.AddColumn( + POSTags, "NamedEntities", each UnknownNamedEntityRecognizer(Record.Field(_, textColumn)) + ), + Dependencies = Table.AddColumn( + NamedEntities, "Dependencies", each UnknownDependencyParser(Record.Field(_, textColumn)) + ), + // Semantic analysis with unknown functions + Embeddings = Table.AddColumn( + Dependencies, "Embeddings", each UnknownTextEmbedder(Record.Field(_, textColumn)) + ), + Similarities = UnknownSemanticSimilarityCalculator(Embeddings, "Embeddings"), + SemanticClusters = UnknownSemanticClusterer(Embeddings, "Embeddings"), + // Sentiment analysis with unknown functions + Sentiments = Table.AddColumn( + Embeddings, "Sentiment", each UnknownSentimentAnalyzer(Record.Field(_, textColumn)) + ), + Emotions = Table.AddColumn(Sentiments, "Emotions", each UnknownEmotionDetector(Record.Field(_, textColumn))), + // Topic modeling with unknown functions + TopicModel = UnknownTopicModeler(textData, textColumn), + DocumentTopics = UnknownDocumentTopicAssigner(textData, TopicModel), + TopicEvolution = UnknownTopicEvolutionAnalyzer(DocumentTopics), + // Text classification with unknown functions + Categories = Table.AddColumn(Emotions, "Categories", each UnknownTextClassifier(Record.Field(_, textColumn))), + Intent = Table.AddColumn(Categories, "Intent", each UnknownIntentClassifier(Record.Field(_, textColumn))), + // Information extraction with unknown functions + KeyPhrases = Table.AddColumn( + Intent, "KeyPhrases", each UnknownKeyPhraseExtractor(Record.Field(_, textColumn)) + ), + Relationships = Table.AddColumn( + KeyPhrases, "Relationships", each UnknownRelationshipExtractor(Record.Field(_, textColumn)) + ), + // Text generation with unknown functions + Summaries = Table.AddColumn( + Relationships, "Summary", each UnknownTextSummarizer(Record.Field(_, textColumn)) + ), + GeneratedQuestions = Table.AddColumn( + Summaries, "Questions", each UnknownQuestionGenerator(Record.Field(_, textColumn)) + ), + // Text quality metrics with unknown functions + Readability = Table.AddColumn( + GeneratedQuestions, "Readability", each UnknownReadabilityAnalyzer(Record.Field(_, textColumn)) + ), + Complexity = Table.AddColumn( + Readability, "Complexity", each UnknownComplexityAnalyzer(Record.Field(_, textColumn)) + ), + // Statistical analysis with unknown functions + TokenStats = UnknownTokenStatistics(textData, textColumn), + VocabularyStats = UnknownVocabularyAnalyzer(textData, textColumn), + NGramAnalysis = UnknownNGramAnalyzer(textData, textColumn, {2, 3, 4}), + CollocationsAnalysis = UnknownCollocationAnalyzer(textData, textColumn) + in + [ + Data = [ + Original = textData, + Cleaned = CleanedText, + Processed = Complexity + ], + Linguistic = [ + Languages = Languages, + POSTags = POSTags, + NamedEntities = NamedEntities, + Dependencies = Dependencies + ], + Semantic = [ + Embeddings = Embeddings, + Similarities = Similarities, + Clusters = SemanticClusters + ], + Sentiment = [ + Scores = Sentiments, + Emotions = Emotions + ], + Topics = [ + Model = TopicModel, + DocumentTopics = DocumentTopics, + Evolution = TopicEvolution + ], + Classification = [ + Categories = Categories, + Intent = Intent + ], + Extraction = [ + KeyPhrases = KeyPhrases, + Relationships = Relationships + ], + Generation = [ + Summaries = Summaries, + Questions = GeneratedQuestions + ], + Quality = [ + Readability = Readability, + Complexity = Complexity + ], + Statistics = [ + Tokens = TokenStats, + Vocabulary = VocabularyStats, + NGrams = NGramAnalysis, + Collocations = CollocationsAnalysis + ] + ]; diff --git a/src/test/testUtils/asyncTestUtils.ts b/src/test/testUtils/asyncTestUtils.ts new file mode 100644 index 00000000..eb6f4a4c --- /dev/null +++ b/src/test/testUtils/asyncTestUtils.ts @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ICancellationToken } from "@microsoft/powerquery-parser"; +import { setImmediate } from "timers"; + +/** + * Test cancellation token interface that extends ICancellationToken with additional test methods. + */ +export interface ITestCancellationToken extends ICancellationToken { + /** + * Gets the number of times isCancelled or throwIfCancelled has been called. + */ + getCallCount(): number; +} + +export interface TestCancellationTokenOptions { + /** + * Number of calls to isCancelled/throwIfCancelled before triggering cancellation. + * If not specified, token will only be cancelled via manual cancel() call. + */ + cancelAfterCount?: number; + + /** + * Delay before setting cancelled state. + * - undefined: Synchronous cancellation (for deterministic tests) + * - 0: Use setImmediate for immediate async cancellation (better for async validation) + * - >0: Use setTimeout with the specified delay in milliseconds + */ + asyncDelayMs?: number; + + /** + * Optional logging function. + * When provided, will be called with trace messages during cancellation. + */ + log?: (message: string) => void; +} + +/** + * Creates a test cancellation token that can be cancelled manually or after a specified number of calls. + * This is useful for testing cancellation behavior in async operations. + * + * @param options Configuration for the cancellation token behavior + * @returns A cancellation token with a cancel method for testing + */ +export function createTestCancellationToken(options?: TestCancellationTokenOptions): ITestCancellationToken { + let isCancelled: boolean = false; + let callCount: number = 0; + let cancellationScheduled: boolean = false; // Prevent multiple async cancellations + const cancelAfterCount: number | undefined = options?.cancelAfterCount; + const asyncDelayMs: number | undefined = options?.asyncDelayMs; + const log: ((message: string) => void) | undefined = options?.log; + + const checkAndTriggerCancellation: () => void = (): void => { + callCount += 1; + + if (cancelAfterCount !== undefined && callCount >= cancelAfterCount && !isCancelled && !cancellationScheduled) { + cancellationScheduled = true; // Mark that we've scheduled cancellation + + if (asyncDelayMs === undefined) { + // Synchronous cancellation for deterministic tests + isCancelled = true; + + if (log) { + log(`Cancellation triggered at call ${callCount} (threshold: ${cancelAfterCount}) [synchronous]`); + } + } else if (asyncDelayMs === 0) { + // Use setImmediate for immediate async cancellation + setImmediate(() => { + isCancelled = true; + + if (log) { + log( + `Cancellation triggered at call ${callCount} (threshold: ${cancelAfterCount}) [setImmediate]`, + ); + } + }); + } else { + // Use setTimeout with the specified delay + setTimeout(() => { + isCancelled = true; + + if (log) { + log( + `Cancellation triggered at call ${callCount} (threshold: ${cancelAfterCount}) [setTimeout:${asyncDelayMs}ms]`, + ); + } + }, asyncDelayMs); + } + } + }; + + return { + isCancelled: (): boolean => { + checkAndTriggerCancellation(); + + return isCancelled; + }, + throwIfCancelled: (): void => { + checkAndTriggerCancellation(); + + if (isCancelled) { + throw new Error("Operation was cancelled"); + } + }, + cancel: (_reason: string): void => { + isCancelled = true; + }, + getCallCount: (): number => callCount, + }; +} diff --git a/src/test/testUtils/index.ts b/src/test/testUtils/index.ts index 8d0c80f8..b054fe3a 100644 --- a/src/test/testUtils/index.ts +++ b/src/test/testUtils/index.ts @@ -4,6 +4,7 @@ export * from "./abridgedTestUtils"; export * from "./analysisTestUtils"; export * from "./assertEqualTestUtils"; +export * from "./asyncTestUtils"; export * from "./autocompleteTestUtils"; export * from "./inspectionTestUtils"; export * from "./parseTestUtils"; diff --git a/src/test/testUtils/validationTestUtils.ts b/src/test/testUtils/validationTestUtils.ts index e1ea2feb..84588225 100644 --- a/src/test/testUtils/validationTestUtils.ts +++ b/src/test/testUtils/validationTestUtils.ts @@ -48,6 +48,66 @@ export async function assertValidate(params: { return triedValidation.value; } +/** + * Asserts that a validation result is an error and that the error message contains the expected text. + */ +export function assertValidationError( + result: Result, + expectedMessageContains: string, + assertionMessage?: string, +): void { + const message: string = + assertionMessage ?? `Expected validation to return error containing '${expectedMessageContains}'`; + + expect(ResultUtils.isError(result), message).to.be.true; + + if (ResultUtils.isError(result)) { + expect(result.error.message).to.contain(expectedMessageContains); + } +} + +/** + * Asserts that a validation result is an error caused by cancellation. + */ +export function assertValidationCancelled( + result: Result, + assertionMessage?: string, +): void { + assertValidationError( + result, + "cancelled", + assertionMessage ?? "Expected validation to return error due to cancellation", + ); +} + +/** + * Asserts that a validation result is successful (not an error). + */ +export function assertValidationSuccess(result: Result, assertionMessage?: string): void { + const message: string = assertionMessage ?? "Expected validation to succeed"; + expect(ResultUtils.isOk(result), message).to.be.true; +} + +/** + * Handles validation results that could be either successful or cancelled due to timing. + * This is useful for tests where cancellation timing is non-deterministic. + */ +export function assertValidationSuccessOrCancelled( + result: Result, + onSuccess?: () => void, + onCancelled?: () => void, +): void { + if (ResultUtils.isOk(result)) { + // Validation completed successfully + expect(result.value).to.not.be.undefined; + onSuccess?.(); + } else { + // Expect cancellation error + expect(result.error.message).to.contain("cancelled"); + onCancelled?.(); + } +} + export async function assertValidateDiagnostics(params: { readonly text: string; readonly analysisSettings: PQLS.AnalysisSettings; diff --git a/src/test/utils/promiseUtils.test.ts b/src/test/utils/promiseUtils.test.ts new file mode 100644 index 00000000..b932b209 --- /dev/null +++ b/src/test/utils/promiseUtils.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import "mocha"; +import { assert, expect } from "chai"; +import { ICancellationToken } from "@microsoft/powerquery-parser"; + +import * as TestUtils from "../testUtils"; +import { processSequentiallyWithCancellation } from "../../powerquery-language-services/promiseUtils"; + +function isErrorWithMessage(error: unknown): error is { message: string } { + return ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message: unknown }).message === "string" + ); +} + +/** + * Helper function to test that an async operation throws an error with a specific message + */ +async function expectAsyncError( + operation: () => Promise, + expectedMessage: string, + failureMessage?: string, +): Promise { + try { + await operation(); + assert.fail(failureMessage ?? `Expected operation to throw an error containing "${expectedMessage}"`); + } catch (error: unknown) { + if (isErrorWithMessage(error)) { + expect(error.message).to.contain(expectedMessage); + } else { + assert.fail("Caught error is not an object with a message property"); + } + } +} + +/** + * Helper function to test cancellation behavior with timeout + */ +async function expectCancellationAfterTimeout( + operation: () => Promise, + cancellationToken: ICancellationToken, + timeoutMs: number, + cancellationReason: string = "Test cancellation", + additionalAssertions?: () => void, +): Promise { + // Cancel after the specified timeout + setTimeout(() => { + cancellationToken.cancel(cancellationReason); + }, timeoutMs); + + await expectAsyncError(operation, "cancelled", "Expected processing to be cancelled"); + + // Run any additional assertions + additionalAssertions?.(); +} + +/** + * Interface for test setup return values + */ +interface TestSetup { + readonly items: string[]; + readonly processor: (item: string) => Promise; + readonly cancellationToken: ICancellationToken; + readonly processedItems: string[]; + readonly processorCallCount: () => number; +} + +/** + * Creates common test setup for string processing tests + */ +function testSetup(options?: { + items?: string[]; + delayMs?: number; + trackProcessed?: boolean; + errorOnItem?: string; + countCalls?: boolean; +}): TestSetup { + const items: string[] = options?.items ?? ["a", "b", "c"]; + const delayMs: number = options?.delayMs ?? 0; + const trackProcessed: boolean = options?.trackProcessed ?? false; + const errorOnItem: string | undefined = options?.errorOnItem; + const countCalls: boolean = options?.countCalls ?? false; + + const cancellationToken: ICancellationToken = TestUtils.createTestCancellationToken(); + + const processedItems: string[] = []; + let processorCallCount: number = 0; + + const processor: (item: string) => Promise = async (item: string): Promise => { + if (countCalls) { + processorCallCount += 1; + } + + if (trackProcessed) { + processedItems.push(item); + } + + if (errorOnItem && item === errorOnItem) { + throw new Error("Test processor error"); + } + + if (delayMs > 0) { + await new Promise((resolve: (value: unknown) => void) => setTimeout(resolve, delayMs)); + } + + return item.toUpperCase(); + }; + + return { + items, + processor, + cancellationToken, + processedItems, + processorCallCount: (): number => processorCallCount, + }; +} + +describe("Promise Utils", () => { + describe("processSequentiallyWithCancellation", () => { + it("should process all items sequentially when no cancellation token is provided", async () => { + const { items, processor }: TestSetup = testSetup({ + delayMs: 10, + }); + + const result: string[] = await processSequentiallyWithCancellation(items, processor); + expect(result).to.deep.equal(["A", "B", "C"]); + }); + + it("should process all items when cancellation token is not cancelled", async () => { + const { items, processor, cancellationToken }: TestSetup = testSetup({ + delayMs: 10, + }); + + const result: string[] = await processSequentiallyWithCancellation(items, processor, cancellationToken); + expect(result).to.deep.equal(["A", "B", "C"]); + expect(cancellationToken.isCancelled()).to.be.false; + }); + + it("should stop processing when cancellation token is cancelled", async () => { + const { items, processor, cancellationToken, processedItems }: TestSetup = testSetup({ + items: ["a", "b", "c", "d", "e"], + delayMs: 20, + trackProcessed: true, + }); + + await expectCancellationAfterTimeout( + () => processSequentiallyWithCancellation(items, processor, cancellationToken), + cancellationToken, + 35, // Should allow first item and start of second + "Cancelled during processing", + () => { + // Should have processed at least the first item, but not all + expect(processedItems.length).to.be.greaterThan(0); + expect(processedItems.length).to.be.lessThan(items.length); + }, + ); + }); + + it("should reject immediately if cancellation token is already cancelled", async () => { + const { items, processor, cancellationToken }: TestSetup = testSetup(); + + cancellationToken.cancel("Pre-cancelled"); + + await expectAsyncError( + () => processSequentiallyWithCancellation(items, processor, cancellationToken), + "cancelled", + "Expected processing to be rejected due to pre-cancelled token", + ); + }); + + it("should handle empty arrays", async () => { + const { items, processor, cancellationToken }: TestSetup = testSetup({ + items: [], + }); + + const result: string[] = await processSequentiallyWithCancellation(items, processor, cancellationToken); + expect(result).to.deep.equal([]); + }); + + it("should handle processor errors normally when not cancelled", async () => { + const { items, processor, cancellationToken }: TestSetup = testSetup({ + errorOnItem: "b", + }); + + await expectAsyncError( + () => processSequentiallyWithCancellation(items, processor, cancellationToken), + "Test processor error", + "Expected processing to be rejected due to processor error", + ); + }); + + it("should check cancellation before each item", async () => { + const { items, processor, cancellationToken, processorCallCount }: TestSetup = testSetup({ + countCalls: true, + }); + + // Cancel before any processing + cancellationToken.cancel("Pre-cancelled"); + + await expectAsyncError( + () => processSequentiallyWithCancellation(items, processor, cancellationToken), + "cancelled", + "Expected processing to be cancelled", + ); + + expect(processorCallCount()).to.equal(0, "Processor should not be called when pre-cancelled"); + }); + }); + + describe("cancellation timing behavior", () => { + it("should demonstrate different cancellation points in sequential processing", async () => { + const { items, processor, cancellationToken, processedItems }: TestSetup = testSetup({ + items: ["first", "second", "third", "fourth", "fifth"], + delayMs: 30, + trackProcessed: true, + }); + + await expectCancellationAfterTimeout( + () => processSequentiallyWithCancellation(items, processor, cancellationToken), + cancellationToken, + 75, // Cancel after 75ms (should process ~2-3 items) + "Timed cancellation", + () => { + console.log( + `Processed ${processedItems.length} items before cancellation: [${processedItems.join(", ")}]`, + ); + + // Should have processed some but not all items + expect(processedItems.length).to.be.greaterThan(0); + expect(processedItems.length).to.be.lessThan(items.length); + + // Should have processed items in order + for (let i: number = 0; i < processedItems.length; i = i + 1) { + expect(processedItems[i]).to.equal(items[i]); + } + }, + ); + }); + }); +}); diff --git a/src/test/validation/asyncValidation.test.ts b/src/test/validation/asyncValidation.test.ts new file mode 100644 index 00000000..5e63a6cc --- /dev/null +++ b/src/test/validation/asyncValidation.test.ts @@ -0,0 +1,428 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import "mocha"; +import * as fs from "fs"; +import * as path from "path"; +import { expect } from "chai"; + +import { CommonError, Result, ResultUtils } from "@microsoft/powerquery-parser"; + +import * as ValidateTestUtils from "../testUtils/validationTestUtils"; + +import { AnalysisSettings, validate, ValidateOk, ValidationSettings } from "../../powerquery-language-services"; +import { TestConstants, TestUtils } from ".."; +import { ITestCancellationToken } from "../testUtils/asyncTestUtils"; + +const TEST_TIMEOUT_MS: number = 5000; +const TEST_SLOW_MS: number = 1000; + +// ******************* +// TODO: consolidate test code and cleanup test cases +// ******************* + +describe("Async Validation", () => { + // Set to true to enable console logging in tests for debugging + const enableConsoleTrace: boolean = true; + + const consoleLogger: ((message: string) => void) | undefined = enableConsoleTrace + ? (message: string): void => console.log(message) + : undefined; + + const analysisSettings: AnalysisSettings = TestConstants.SimpleLibraryAnalysisSettings; + + const baseValidationSettings: ValidationSettings = { + ...TestConstants.StandardLibraryValidateAllSettings, + isWorkspaceCacheAllowed: false, + }; + + let largeSectionDocumentWithDiagnosticsText: string; + + before(() => { + // Load the large section document with diagnostics for all tests + const diagnosticsFilePath: string = path.join( + __dirname, + "..", + "files", + "LargeSectionDocument_WithDiagnostics.pq", + ); + + largeSectionDocumentWithDiagnosticsText = fs.readFileSync(diagnosticsFilePath, "utf8"); + }); + + describe("Large document validation", () => { + it("should validate document with diagnostic errors without cancellation", async () => { + const validationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken: undefined, // No cancellation for this test + }; + + const result: ValidateOk = await ValidateTestUtils.assertValidate({ + text: largeSectionDocumentWithDiagnosticsText, + analysisSettings, + validationSettings, + }); + + // Validation should complete successfully + expect(result).to.not.be.undefined; + expect(result.diagnostics).to.be.an("array"); + expect(result.hasSyntaxError).to.be.false; + + // Should have diagnostic errors due to unknown identifiers + expect(result.diagnostics.length).to.be.greaterThan(0, "Document with diagnostics should have errors"); + }); + + it("should respect cancellation token when cancelled after 3 cancellation checks", async () => { + const cancellationToken: ITestCancellationToken = TestUtils.createTestCancellationToken({ + cancelAfterCount: 3, + asyncDelayMs: 0, + log: consoleLogger, + }); + + const validationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken, + }; + + const result: Result = await validate( + TestUtils.mockDocument(largeSectionDocumentWithDiagnosticsText), + analysisSettings, + validationSettings, + ); + + if (consoleLogger) { + consoleLogger(`Test completed - Total cancellation calls: ${cancellationToken.getCallCount()}`); + } + + // This test expects cancellation to occur with immediate cancellation + ValidateTestUtils.assertValidationCancelled( + result, + "Expected validation to be cancelled after 3 cancellation checks with immediate cancellation", + ); + }); + + it("should respect cancellation token when cancelled after 100 cancellation checks", async () => { + const cancellationToken: ITestCancellationToken = TestUtils.createTestCancellationToken({ + cancelAfterCount: 100, // Cancel after 100 calls to isCancelled/throwIfCancelled + log: consoleLogger, + }); + + const validationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken, + }; + + const result: Result = await validate( + TestUtils.mockDocument(largeSectionDocumentWithDiagnosticsText), + analysisSettings, + validationSettings, + ); + + if (consoleLogger) { + consoleLogger(`Test completed - Total cancellation calls: ${cancellationToken.getCallCount()}`); + } + + ValidateTestUtils.assertValidationCancelled(result); + }); + + it("should respect cancellation token when cancelled after many cancellation checks", async () => { + const cancellationToken: ITestCancellationToken = TestUtils.createTestCancellationToken({ + cancelAfterCount: 1000, // Cancel after 1000 calls to isCancelled/throwIfCancelled + log: consoleLogger, + }); + + const validationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken, + }; + + const result: Result = await validate( + TestUtils.mockDocument(largeSectionDocumentWithDiagnosticsText), + analysisSettings, + validationSettings, + ); + + if (consoleLogger) { + consoleLogger(`Test completed - Total cancellation calls: ${cancellationToken.getCallCount()}`); + } + + ValidateTestUtils.assertValidationCancelled(result); + }); + + it("should handle cancellation gracefully at different thresholds", async () => { + const thresholds: number[] = [5, 25, 50, 200]; // Different cancellation thresholds + + for (const threshold of thresholds) { + if (threshold <= 25) { + const cancellationToken: ITestCancellationToken = TestUtils.createTestCancellationToken({ + cancelAfterCount: threshold, + asyncDelayMs: 0, + log: consoleLogger, + }); + + const validationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken, + }; + + // eslint-disable-next-line no-await-in-loop + const result: Result = await validate( + TestUtils.mockDocument(largeSectionDocumentWithDiagnosticsText), + analysisSettings, + validationSettings, + ); + + if (consoleLogger) { + consoleLogger( + `Threshold ${threshold} test completed - Total cancellation calls: ${cancellationToken.getCallCount()}`, + ); + } + + ValidateTestUtils.assertValidationCancelled( + result, + `Expected validation to be cancelled with low threshold of ${threshold} calls`, + ); + } else { + const cancellationToken: ITestCancellationToken = TestUtils.createTestCancellationToken({ + cancelAfterCount: threshold, + log: consoleLogger, + asyncDelayMs: 0, + }); + + const validationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken, + }; + + // eslint-disable-next-line no-await-in-loop + const result: Result = await validate( + TestUtils.mockDocument(largeSectionDocumentWithDiagnosticsText), + analysisSettings, + validationSettings, + ); + + if (consoleLogger) { + consoleLogger( + `Threshold ${threshold} test completed - Total cancellation calls: ${cancellationToken.getCallCount()}`, + ); + } + + ValidateTestUtils.assertValidationCancelled(result); + } + } + }); + + it("should demonstrate performance benefit of cancellation", async () => { + // Test that cancelled validation is faster than completed validation + + // First, measure time for completed validation + const startComplete: number = Date.now(); + + const completedValidationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken: undefined, // No cancellation + }; + + await ValidateTestUtils.assertValidate({ + text: largeSectionDocumentWithDiagnosticsText, + analysisSettings, + validationSettings: completedValidationSettings, + }); + + const completeDuration: number = Date.now() - startComplete; + + // Then, measure time for cancelled validation with early threshold + const startCancelled: number = Date.now(); + + const cancellationToken: ITestCancellationToken = TestUtils.createTestCancellationToken({ + cancelAfterCount: 10, // Cancel after 10 calls + asyncDelayMs: 0, // Immediate cancellation for deterministic testing + log: consoleLogger, + }); + + const cancelledValidationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken, + }; + + let cancellationDuration: number = 0; + let wasCancelled: boolean = false; + + const result: Result = await validate( + TestUtils.mockDocument(largeSectionDocumentWithDiagnosticsText), + analysisSettings, + cancelledValidationSettings, + ); + + if (consoleLogger) { + consoleLogger( + `Performance test completed - Total cancellation calls: ${cancellationToken.getCallCount()}`, + ); + } + + if (ResultUtils.isOk(result)) { + // This test expects cancellation to occur, so fail if it doesn't + throw new Error( + `Expected validation to be cancelled with threshold of 10 calls, but it completed successfully in ${Date.now() - startCancelled}ms`, + ); + } else { + cancellationDuration = Date.now() - startCancelled; + wasCancelled = true; + + ValidateTestUtils.assertValidationCancelled(result); + } + + // Verify that cancellation provided a performance benefit + expect(wasCancelled).to.be.true; + + expect(cancellationDuration).to.be.lessThan( + completeDuration, + `Cancelled validation (${cancellationDuration}ms) should be faster than complete validation (${completeDuration}ms)`, + ); + }); + }); + + describe("Cancellation token behavior", () => { + it("should not throw when cancellation token is undefined", async () => { + const validationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken: undefined, + }; + + const result: ValidateOk = await ValidateTestUtils.assertValidate({ + text: largeSectionDocumentWithDiagnosticsText, + analysisSettings, + validationSettings, + }); + + expect(result).to.not.be.undefined; + }); + + it("should not throw when cancellation token is not cancelled", async () => { + const cancellationToken: ITestCancellationToken = TestUtils.createTestCancellationToken({ + log: consoleLogger, + }); + + const validationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken, + }; + + const result: ValidateOk = await ValidateTestUtils.assertValidate({ + text: largeSectionDocumentWithDiagnosticsText, + analysisSettings, + validationSettings, + }); + + if (consoleLogger) { + consoleLogger( + `No cancellation test completed - Total cancellation calls: ${cancellationToken.getCallCount()}`, + ); + } + + expect(result).to.not.be.undefined; + expect(cancellationToken.isCancelled()).to.be.false; + }); + + it("should throw immediately when cancellation token is already cancelled", async () => { + const cancellationToken: ITestCancellationToken = TestUtils.createTestCancellationToken({ + log: consoleLogger, + }); + + cancellationToken.cancel("Pre-cancelled for testing"); // Cancel before starting + + const validationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken, + }; + + const result: Result = await validate( + TestUtils.mockDocument(largeSectionDocumentWithDiagnosticsText), + analysisSettings, + validationSettings, + ); + + if (consoleLogger) { + consoleLogger( + `Pre-cancelled test completed - Total cancellation calls: ${cancellationToken.getCallCount()}`, + ); + } + + ValidateTestUtils.assertValidationCancelled( + result, + "Expected validation to return error due to pre-cancelled token", + ); + }); + }); + + describe("Async validation with different validation settings", () => { + it("should respect cancellation with all validation checks enabled", async () => { + const cancellationToken: ITestCancellationToken = TestUtils.createTestCancellationToken({ + cancelAfterCount: 5, // Cancel after 5 calls to test early cancellation with all checks + asyncDelayMs: 0, // Immediate cancellation for deterministic testing + log: consoleLogger, + }); + + const validationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken, + checkInvokeExpressions: true, + checkUnknownIdentifiers: true, + checkForDuplicateIdentifiers: true, + }; + + const result: Result = await validate( + TestUtils.mockDocument(largeSectionDocumentWithDiagnosticsText), + analysisSettings, + validationSettings, + ); + + if (consoleLogger) { + consoleLogger( + `All validation checks test completed - Total cancellation calls: ${cancellationToken.getCallCount()}`, + ); + } + + // This test expects cancellation to occur with a low threshold + ValidateTestUtils.assertValidationCancelled( + result, + "Expected validation to be cancelled after 5 cancellation checks with all validation enabled", + ); + }); + + it("should respect cancellation with only specific checks enabled", async () => { + const cancellationToken: ITestCancellationToken = TestUtils.createTestCancellationToken({ + cancelAfterCount: 5, // Cancel after 5 calls to test with fewer validation checks + asyncDelayMs: undefined, // Synchronous cancellation for deterministic testing + log: consoleLogger, + }); + + const validationSettings: ValidationSettings = { + ...baseValidationSettings, + cancellationToken, + checkInvokeExpressions: false, + checkUnknownIdentifiers: true, + checkForDuplicateIdentifiers: false, + }; + + const result: Result = await validate( + TestUtils.mockDocument(largeSectionDocumentWithDiagnosticsText), + analysisSettings, + validationSettings, + ); + + if (consoleLogger) { + consoleLogger( + `Specific validation checks test completed - Total cancellation calls: ${cancellationToken.getCallCount()}`, + ); + } + + ValidateTestUtils.assertValidationCancelled( + result, + "Expected validation to be cancelled after 5 cancellation checks with specific validation enabled", + ); + }); + }); +}) + .timeout(TEST_TIMEOUT_MS) + .slow(TEST_SLOW_MS);