diff --git a/app/exec/extension/_lib/extension-composer-factory.ts b/app/exec/extension/_lib/extension-composer-factory.ts index 6fb7ea0e..b554ec1a 100644 --- a/app/exec/extension/_lib/extension-composer-factory.ts +++ b/app/exec/extension/_lib/extension-composer-factory.ts @@ -34,11 +34,21 @@ export class ComposerFactory { break; default: if (!settings.bypassValidation) { - throw new Error( + const message = "'" + - target.id + - "' is not a recognized target. Valid targets are 'Microsoft.VisualStudio.Services', 'Microsoft.VisualStudio.Services.Integration', 'Microsoft.VisualStudio.Offer'", - ); + target.id + + "' is not a recognized target. Valid targets are 'Microsoft.VisualStudio.Services', 'Microsoft.VisualStudio.Services.Integration', 'Microsoft.VisualStudio.Offer'"; + const targetWithSource = target; + const err: any = new Error(message); + err.validationIssues = [ + { + file: targetWithSource.__origin || null, + line: targetWithSource.__line || null, + col: targetWithSource.__col || null, + message: message, + }, + ]; + throw err; } break; } diff --git a/app/exec/extension/_lib/interfaces.ts b/app/exec/extension/_lib/interfaces.ts index eff30f3c..c5a1f0c1 100644 --- a/app/exec/extension/_lib/interfaces.ts +++ b/app/exec/extension/_lib/interfaces.ts @@ -136,8 +136,8 @@ export interface MergeSettings { root: string; /* - * Manifest in the form of a standard Node.js CommonJS module with an exported function. - * The function takes an environment as a parameter and must return the manifest JSON object. + * Manifest in the form of a standard Node.js CommonJS module with an exported function. + * The function takes an environment as a parameter and must return the manifest JSON object. * Environment variables are specified with the env command line parameter. * If this is present then manifests and manifestGlobs are ignored. */ @@ -183,6 +183,11 @@ export interface MergeSettings { * which supports comments, unquoted keys, etc. */ json5: boolean; + + /** + * If true, task.json validation warnings are treated as errors. + */ + warningsAsErrors?: boolean; } export interface PackageSettings { @@ -239,6 +244,15 @@ export interface PublishSettings { bypassScopeCheck?: boolean; } +/** + * Common extension identity/version metadata. + */ +export interface ExtensionVersionInfo { + extensionId: string; + version: string; + publisher: string; +} + /*** Types related to localized resources ***/ export interface ResourcesFile { diff --git a/app/exec/extension/_lib/merger.ts b/app/exec/extension/_lib/merger.ts index 23d0bda0..9d22b1e5 100644 --- a/app/exec/extension/_lib/merger.ts +++ b/app/exec/extension/_lib/merger.ts @@ -16,10 +16,13 @@ import fs = require("fs"); import { glob } from "glob"; import jju = require("jju"); import jsonInPlace = require("json-in-place"); +import * as jsonc from "jsonc-parser"; import loc = require("./loc"); import path = require("path"); import trace = require("../../../lib/trace"); import version = require("../../../lib/dynamicVersion"); +import { formatDiagnostic, normalizeIssue } from "../../../lib/diagnostics"; +import { offsetToLineCol, pointerLocation } from "../../../lib/jsoncLocation"; import { promisify } from "util"; import { readdir, readFile, writeFile, lstat } from "fs"; @@ -49,6 +52,34 @@ export class Merger { this.manifestBuilders = []; } + private parseManifestText(jsonData: string): { data: any; pointers: any } { + try { + const parseErrors: jsonc.ParseError[] = []; + const rootNode = jsonc.parseTree(jsonData, parseErrors, { + allowTrailingComma: !!this.settings.json5, + disallowComments: !this.settings.json5, + }); + if (parseErrors.length > 0 || !rootNode) { + const parseErr: any = new Error("Invalid JSON/JSONC content."); + parseErr.parseErrors = parseErrors; + throw parseErr; + } + return { + data: jsonc.getNodeValue(rootNode), + pointers: { + sourceText: jsonData, + root: rootNode, + }, + }; + } catch (strictErr) { + if (this.settings.json5) { + const data = jju.parse(jsonData); + return { data, pointers: null }; + } + throw strictErr; + } + } + private async gatherManifests(): Promise { trace.debug("merger.gatherManifests"); @@ -128,12 +159,32 @@ export class Merger { promisify(readFile)(file, "utf8").then(data => { const jsonData = data.replace(/^\uFEFF/, ""); try { - const result = this.settings.json5 ? jju.parse(jsonData) : JSON.parse(jsonData); + const parsed = this.parseManifestText(jsonData); + let result: any = parsed.data; + result.__pointerMap = parsed.pointers; result.__origin = file; // save the origin in order to resolve relative paths later. return result; } catch (err) { trace.error("Error parsing the JSON in %s: ", file); trace.debug(jsonData, null); + const parseErrors = err && (err).parseErrors; + if (!err || !Array.isArray((err).validationIssues)) { + let line: number | null = null; + let col: number | null = null; + if (Array.isArray(parseErrors) && parseErrors.length > 0 && typeof parseErrors[0].offset === "number") { + const loc = offsetToLineCol(jsonData, parseErrors[0].offset); + line = loc.line; + col = loc.col; + } + (err).validationIssues = [ + { + file: file, + line: line, + col: col, + message: "Could not parse JSON.", + }, + ]; + } throw err; } }), @@ -153,10 +204,34 @@ export class Merger { let allContributions: any[] = []; partials.forEach(partial => { if (_.isArray(partial["targets"])) { - targets = targets.concat(partial["targets"]); + partial["targets"].forEach((target: any, targetIndex: number) => { + const idLoc = pointerLocation(partial.__pointerMap, `/targets/${targetIndex}/id`); + const targetWithSource: any = _.assign({}, target, { + __origin: partial.__origin || null, + __line: idLoc.line, + __col: idLoc.col, + }); + targets.push(targetWithSource); + }); } if (_.isArray(partial["contributions"])) { - allContributions = allContributions.concat(partial["contributions"]); + partial["contributions"].forEach((contribution: any, contributionIndex: number) => { + const nameLoc = pointerLocation( + partial.__pointerMap, + `/contributions/${contributionIndex}/properties/name`, + ); + const contributionLoc = pointerLocation( + partial.__pointerMap, + `/contributions/${contributionIndex}`, + ); + allContributions.push( + _.assign({}, contribution, { + __origin: partial.__origin || null, + __line: nameLoc.line !== null ? nameLoc.line : contributionLoc.line, + __col: nameLoc.col !== null ? nameLoc.col : contributionLoc.col, + }), + ); + }); } }); this.extensionComposer = ComposerFactory.GetComposer(this.settings, targets); @@ -281,10 +356,17 @@ export class Merger { if (validationResult.length === 0 || this.settings.bypassValidation) { return components; } else { - throw new Error( + const validationErr: any = new Error( "There were errors with your extension. Address the following and re-run the tool.\n" + validationResult, ); + validationErr.validationIssues = validationResult.map(message => ({ + file: null, + line: null, + col: null, + message: message, + })); + throw validationErr; } }); }); @@ -397,6 +479,14 @@ export class Merger { } private async validateBuildTaskContributions(contributions: any[]): Promise { + const warnings: Array<{ file: string | null; line: number | null; col: number | null; message: string }> = []; + + const addWarning = (message: string, file: string | null = null, line: number | null = null, col: number | null = null) => { + const warning = { file, line, col, message }; + warnings.push(warning); + console.warn(formatDiagnostic(warning, "warning")); + }; + try { // Filter contributions to only build tasks const buildTaskContributions = contributions.filter(contrib => @@ -440,22 +530,27 @@ export class Merger { } } } catch (err) { - trace.warn(`Error reading task directory ${absoluteTaskPath}: ${err}`); + addWarning(`Error reading task directory ${absoluteTaskPath}: ${err}`, absoluteTaskPath, 1, 1); } } // Validate task.json files for this contribution with backwards compatibility checking if (contributionTaskJsonPaths.length > 0) { trace.debug(`Validating ${contributionTaskJsonPaths.length} task.json files for contribution ${contrib.id || taskPath}`); - + for (const taskJsonPath of contributionTaskJsonPaths) { - validate(taskJsonPath, "no task.json in specified directory", contributionTaskJsonPaths); + validate(taskJsonPath, "no task.json in specified directory", contributionTaskJsonPaths, this.settings.json5); } - + // Also collect for global tracking if needed allTaskJsonPaths.push(...contributionTaskJsonPaths); } else { - trace.warn(`Build task contribution '${contrib.id || taskPath}' does not have a task.json file. Expected task.json in ${absoluteTaskPath} or its subdirectories.`); + addWarning( + `Build task contribution '${contrib.id || taskPath}' does not have a task.json file. Expected task.json in ${absoluteTaskPath} or its subdirectories.`, + contrib && contrib.__origin !== undefined ? contrib.__origin : null, + contrib && contrib.__line !== undefined ? contrib.__line : null, + contrib && contrib.__col !== undefined ? contrib.__col : null, + ); } } @@ -466,11 +561,54 @@ export class Merger { trace.debug(`Successfully validated ${allTaskJsonPaths.length} task.json files across ${buildTaskContributions.length} build task contributions`); + if (this.settings.warningsAsErrors && warnings.length > 0) { + const warningsAsErrorsErr: any = new Error( + "Task.json validation produced warnings. Re-run without --warnings-as-errors to treat them as warnings only.\n" + + warnings.map(w => w.message).join("\n"), + ); + warningsAsErrorsErr.validationIssues = warnings.map(warning => ({ + file: warning.file, + line: warning.line, + col: warning.col, + message: warning.message, + })); + throw warningsAsErrorsErr; + } + } catch (err) { const warningMessage = "Please, make sure the task.json file is correct. In the future, this warning will be treated as an error.\n"; - trace.warn(err && err instanceof Error - ? warningMessage + err.message - : `Error occurred while validating build task contributions. ${warningMessage}`); + const emitWarning = (issue: { file: string | null; line: number | null; col: number | null; message: string }) => { + console.warn(formatDiagnostic(issue, "warning")); + }; + if (this.settings.warningsAsErrors) { + if (err && err instanceof Error) { + throw err; + } + throw new Error("Error occurred while validating build task contributions."); + } + const structuredIssues = err && Array.isArray((err).validationIssues) ? (err).validationIssues : null; + if (structuredIssues && structuredIssues.length > 0) { + const normalizedIssues = structuredIssues.map(issue => normalizeIssue(issue)); + const warnedFiles: { [file: string]: boolean } = {}; + normalizedIssues.forEach(issue => { + const fileKey = issue.file ? String(issue.file) : ""; + if (!warnedFiles[fileKey]) { + warnedFiles[fileKey] = true; + emitWarning({ file: issue.file, line: 1, col: 1, message: warningMessage.trim() }); + } + emitWarning(issue); + }); + } else { + emitWarning({ + file: null, + line: null, + col: null, + message: + err && err instanceof Error + ? `${warningMessage}${err.message}` + : `Error occurred while validating build task contributions. ${warningMessage}`, + }); + } } } } diff --git a/app/exec/extension/create.ts b/app/exec/extension/create.ts index 84b86334..4f6dae30 100644 --- a/app/exec/extension/create.ts +++ b/app/exec/extension/create.ts @@ -1,6 +1,6 @@ import { Merger } from "./_lib/merger"; import { VsixManifestBuilder } from "./_lib/vsix-manifest-builder"; -import { MergeSettings, PackageSettings } from "./_lib/interfaces"; +import { ExtensionVersionInfo, MergeSettings, PackageSettings } from "./_lib/interfaces"; import { VsixWriter } from "./_lib/vsix-writer"; import { TfCommand } from "../../lib/tfcommand"; import colors = require("colors"); @@ -11,11 +11,8 @@ export function getCommand(args: string[]): TfCommand { @@ -56,6 +53,7 @@ export class ExtensionCreate extends extBase.ExtensionBase { "overridesFile", "revVersion", "bypassValidation", + "warningsAsErrors", "publisher", "extensionId", "outputPath", diff --git a/app/exec/extension/default.ts b/app/exec/extension/default.ts index 3d1ff135..59105b9b 100644 --- a/app/exec/extension/default.ts +++ b/app/exec/extension/default.ts @@ -6,9 +6,11 @@ import { GalleryBase, CoreExtInfo, PublisherManager, PackagePublisher } from "./ import * as path from "path"; import _ = require("lodash"); import jju = require("jju"); +import * as jsonc from "jsonc-parser"; import args = require("../../lib/arguments"); import https = require("https"); import trace = require("../../lib/trace"); +import { offsetToLineCol } from "../../lib/jsoncLocation"; import { readFile } from "fs"; import { promisify } from "util"; @@ -50,6 +52,7 @@ export interface ExtensionArguments extends CoreArguments { unshareWith: args.ArrayArgument; version: args.StringArgument; vsix: args.ReadableFilePathsArgument; + warningsAsErrors: args.BooleanArgument; zipUri: args.StringArgument; } @@ -133,7 +136,7 @@ export class ExtensionBase extends TfCommand { "shareWith", "Share with", "List of Azure DevOps organization(s) with which to share the extension (space separated).", - args.ArrayArgument, + args.ArrayArgument, null, ); this.registerCommandArgument( @@ -151,6 +154,13 @@ export class ExtensionBase extends TfCommand { ); this.registerCommandArgument("bypassScopeCheck", "Bypass scope check", null, args.BooleanArgument, "false"); this.registerCommandArgument("bypassValidation", "Bypass local validation", null, args.BooleanArgument, "false"); + this.registerCommandArgument( + "warningsAsErrors", + "Warnings as errors", + "Treat task.json validation warnings as errors.", + args.BooleanArgument, + "false", + ); this.registerCommandArgument( "locRoot", "Localization root", @@ -196,6 +206,7 @@ export class ExtensionBase extends TfCommand { this.commandArgs.overridesFile.val(), this.commandArgs.revVersion.val(), this.commandArgs.bypassValidation.val(), + this.commandArgs.warningsAsErrors.val(), this.commandArgs.publisher.val(true), this.commandArgs.extensionId.val(true), this.commandArgs.json5.val(true), @@ -211,6 +222,7 @@ export class ExtensionBase extends TfCommand { overridesFile, revVersion, bypassValidation, + warningsAsErrors, publisher, extensionId, json5, @@ -236,9 +248,48 @@ export class ExtensionBase extends TfCommand { let mergedOverrides = {}; let contentJSON = {}; try { - contentJSON = json5 ? jju.parse(content) : JSON.parse(content); + try { + const parseErrors: jsonc.ParseError[] = []; + const rootNode = jsonc.parseTree(content, parseErrors, { + allowTrailingComma: !!json5, + disallowComments: !json5, + }); + if (parseErrors.length > 0 || !rootNode) { + const parseErr: any = new Error("Invalid JSON/JSONC content."); + parseErr.parseErrors = parseErrors; + throw parseErr; + } + contentJSON = jsonc.getNodeValue(rootNode); + contentJSON["__pointerMap"] = { + sourceText: content, + root: rootNode, + }; + } catch (strictErr) { + if (json5) { + contentJSON = jju.parse(content); + } else { + throw strictErr; + } + } } catch (e) { - throw new Error("Could not parse contents of " + overridesFile[0] + " as JSON. \n"); + let line: number | null = null; + let col: number | null = null; + const parseErrors = e && (e).parseErrors; + if (Array.isArray(parseErrors) && parseErrors.length > 0 && typeof parseErrors[0].offset === "number") { + const loc = offsetToLineCol(content, parseErrors[0].offset); + line = loc.line; + col = loc.col; + } + const parseErr: any = new Error("Could not parse contents of " + overridesFile[0] + " as JSON. \n"); + parseErr.validationIssues = [ + { + file: overridesFile && overridesFile.length > 0 ? overridesFile[0] : null, + line: line, + col: col, + message: "Could not parse JSON.", + }, + ]; + throw parseErr; } contentJSON["__origin"] = overridesFile ? overridesFile[0] : path.join(root[0], "_override.json"); _.merge(mergedOverrides, contentJSON, override); @@ -251,6 +302,7 @@ export class ExtensionBase extends TfCommand { manifestGlobs: manifestGlob, overrides: mergedOverrides, bypassValidation: bypassValidation, + warningsAsErrors: warningsAsErrors, revVersion: revVersion, json5: json5, }; diff --git a/app/exec/extension/publish.ts b/app/exec/extension/publish.ts index f499dca9..dea132af 100644 --- a/app/exec/extension/publish.ts +++ b/app/exec/extension/publish.ts @@ -48,6 +48,7 @@ export class ExtensionPublish extends extBase.ExtensionBase { + return new ExtensionValidate(args); +} + +export interface ValidationIssue { + file: string | null; + line: number | null; + col: number | null; + message: string; +} + +export interface ValidationResult { + source: "manifest"; + status: "success" | "error"; + issues: ValidationIssue[]; +} + +export class ExtensionValidate extends extBase.ExtensionBase { + protected description = + "Validate an extension from manifests without packaging or publishing."; + protected serverCommand = false; + + constructor(passedArgs: string[]) { + super(passedArgs); + } + + protected getHelpArgs(): string[] { + return [ + "root", + "manifestJs", + "env", + "manifests", + "manifestGlobs", + "json5", + "override", + "overridesFile", + "warningsAsErrors", + "publisher", + "extensionId", + "locRoot", + ]; + } + + public async exec(): Promise { + const vsix = await this.commandArgs.vsix.val(true); + if (vsix && vsix.length > 0) { + throw new Error("The --vsix argument is not supported by 'tfx extension validate'. Validate command only supports manifest-based local validation."); + } + + return this.validateManifestInputs(); + } + + private async validateManifestInputs(): Promise { + const mergeSettings = await this.getMergeSettings(); + + // Validation command should not mutate version nor bypass validation checks. + mergeSettings.revVersion = false; + mergeSettings.bypassValidation = false; + + try { + await new Merger(mergeSettings).merge(); + + return { + source: "manifest", + status: "success", + issues: [], + }; + } catch (err) { + return { + source: "manifest", + status: "error", + issues: this.errorToIssues(err), + }; + } + } + + private errorToIssues(err: any): ValidationIssue[] { + const sourceIssues = err && Array.isArray(err.validationIssues) ? err.validationIssues : null; + if (sourceIssues && sourceIssues.length > 0) { + return sourceIssues.map(issue => ({ + file: issue && issue.file !== undefined ? issue.file : null, + line: issue && issue.line !== undefined ? issue.line : null, + col: issue && issue.col !== undefined ? issue.col : null, + message: issue && issue.message ? issue.message : String(issue), + })); + } + + const message = err && err.message ? err.message : String(err); + return [{ file: null, line: null, col: null, message: message }]; + } + + protected friendlyOutput(data: ValidationResult): void { + if (data.status === "success") { + trace.info(colors.green("\n=== Completed operation: validate extension ===")); + trace.info(" - Source: %s", data.source); + trace.info(" - Validation: %s", colors.green("success")); + } else { + trace.info(colors.red("\n=== Completed operation: validate extension ===")); + trace.info(" - Source: %s", data.source); + trace.info(" - Validation: %s", colors.red("failed")); + data.issues.forEach(issue => { + trace.info(formatDiagnostic(issue, "error")); + }); + } + } +} diff --git a/app/lib/diagnostics.ts b/app/lib/diagnostics.ts new file mode 100644 index 00000000..e890a475 --- /dev/null +++ b/app/lib/diagnostics.ts @@ -0,0 +1,32 @@ +export interface DiagnosticIssue { + file: string | null; + line: number | null; + col: number | null; + message: string; +} + +export type DiagnosticSeverity = "warning" | "error"; + +export function normalizeIssue(issue: any): DiagnosticIssue { + return { + file: issue && issue.file !== undefined ? String(issue.file) : null, + line: issue && typeof issue.line === "number" ? issue.line : null, + col: issue && typeof issue.col === "number" ? issue.col : null, + message: issue && issue.message ? String(issue.message) : String(issue), + }; +} + +export function formatDiagnostic(issue: any, severity: DiagnosticSeverity = "error"): string { + const normalized = normalizeIssue(issue); + + if (normalized.file && normalized.line !== null && normalized.col !== null) { + return `${normalized.file}(${normalized.line},${normalized.col}): ${severity}: ${normalized.message}`; + } + if (normalized.file && normalized.line !== null) { + return `${normalized.file}(${normalized.line}): ${severity}: ${normalized.message}`; + } + if (normalized.file) { + return `${normalized.file}: ${severity}: ${normalized.message}`; + } + return `${severity}: ${normalized.message}`; +} diff --git a/app/lib/errorhandler.ts b/app/lib/errorhandler.ts index d0c37812..e044d19e 100644 --- a/app/lib/errorhandler.ts +++ b/app/lib/errorhandler.ts @@ -1,4 +1,29 @@ import trace = require("./trace"); +import { formatDiagnostic, normalizeIssue } from "./diagnostics"; + +function shouldEmitJsonError(): boolean { + const argv = process.argv || []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--json") { + return true; + } + if (arg === "--output" && i + 1 < argv.length && argv[i + 1].toLowerCase() === "json") { + return true; + } + if (arg.indexOf("--output=") === 0 && arg.substring("--output=".length).toLowerCase() === "json") { + return true; + } + } + return false; +} + +function toStructuredIssues(err: any): any[] { + if (!err || !Array.isArray(err.validationIssues)) { + return null; + } + return err.validationIssues.map(issue => normalizeIssue(issue)); +} /** * Formats any error type into a readable string message. @@ -7,22 +32,22 @@ import trace = require("./trace"); function formatError(err: any): string { // Handle AggregateError (from Promise.all/Promise.any failures) if (err && err.name === "AggregateError" && Array.isArray(err.errors)) { - const messages = err.errors.map((e: any, index: number) => + const messages = err.errors.map((e: any, index: number) => ` [${index + 1}] ${formatError(e)}` ); return `Multiple errors occurred:\n${messages.join("\n")}`; } - + // Handle plain strings if (typeof err === "string") { return err; } - + // Handle Error instances - use toString() to preserve "Error: message" format if (err instanceof Error) { return err.toString(); } - + // Handle objects with a custom toString method (not the default Object.prototype.toString) if (err !== null && typeof err === "object" && typeof err.toString === "function" && err.toString !== Object.prototype.toString) { const result = err.toString(); @@ -31,12 +56,12 @@ function formatError(err: any): string { return result; } } - + // Handle objects with a message property (error-like objects) if (typeof err?.message === "string") { return err.message; } - + // Handle plain objects - try JSON serialization if (typeof err === "object" && err !== null) { try { @@ -45,7 +70,7 @@ function formatError(err: any): string { return String(err); } } - + // Fallback for any other type return String(err); } @@ -91,6 +116,27 @@ export function httpErr(obj): any { export function errLog(arg: any): void { trace.debug(arg?.stack); + if (shouldEmitJsonError()) { + const payload: any = { + status: "error", + message: formatError(arg), + }; + const issues = toStructuredIssues(arg); + if (issues) { + payload.issues = issues; + } + console.log(JSON.stringify(payload, null, 4)); + process.exit(-1); + return; + } + const issues = toStructuredIssues(arg); + if (issues && issues.length > 0) { + issues.forEach(issue => { + console.error(formatDiagnostic(issue, "error")); + }); + process.exit(-1); + return; + } trace.error(formatError(arg)); process.exit(-1); } diff --git a/app/lib/jsoncLocation.ts b/app/lib/jsoncLocation.ts new file mode 100644 index 00000000..0588c1f1 --- /dev/null +++ b/app/lib/jsoncLocation.ts @@ -0,0 +1,63 @@ +import * as jsonc from "jsonc-parser"; + +export interface JsoncPointerContext { + sourceText: string; + root: any; +} + +export function escapeJsonPointerToken(token: string): string { + return token.replace(/~/g, "~0").replace(/\//g, "~1"); +} + +function decodeJsonPointerToken(token: string): string { + return token.replace(/~1/g, "/").replace(/~0/g, "~"); +} + +export function pointerLocation( + pointerContext: JsoncPointerContext | null | undefined, + pointerPath: string, +): { line: number | null; col: number | null } { + if (!pointerContext || !pointerContext.root || typeof pointerContext.sourceText !== "string") { + return { line: null, col: null }; + } + + const segments = + pointerPath === "" + ? [] + : pointerPath + .split("/") + .slice(1) + .map(token => decodeJsonPointerToken(token)) + .map(token => (/^\d+$/.test(token) ? parseInt(token, 10) : token)); + + const node = jsonc.findNodeAtLocation(pointerContext.root, segments); + if (!node) { + return { line: null, col: null }; + } + + const locationNode = node.parent && node.parent.type === "property" ? node.parent : node; + return offsetToLineCol(pointerContext.sourceText, locationNode.offset); +} + +export function offsetToLineCol(text: string, offset: number): { line: number; col: number } { + let line = 1; + let col = 1; + + for (let i = 0; i < offset && i < text.length; i++) { + const ch = text.charCodeAt(i); + if (ch === 13) { + if (i + 1 < text.length && text.charCodeAt(i + 1) === 10) { + i++; + } + line++; + col = 1; + } else if (ch === 10) { + line++; + col = 1; + } else { + col++; + } + } + + return { line, col }; +} diff --git a/app/lib/jsonvalidate.ts b/app/lib/jsonvalidate.ts index b74aa10d..ec3d9616 100644 --- a/app/lib/jsonvalidate.ts +++ b/app/lib/jsonvalidate.ts @@ -2,6 +2,9 @@ var fs = require("fs"); import * as path from 'path'; var check = require("validator"); var trace = require("./trace"); +import * as jsonc from "jsonc-parser"; +import { DiagnosticIssue, formatDiagnostic } from "./diagnostics"; +import { escapeJsonPointerToken, offsetToLineCol, pointerLocation } from "./jsoncLocation"; const deprecatedRunners = ["Node", "Node6", "Node10", "Node16"]; @@ -17,31 +20,62 @@ export interface TaskJson { * @return the parsed json file * @throws InvalidDirectoryException if json file doesn't exist, InvalidJsonException on failed parse or *first* invalid field in json */ -export function validate(jsonFilePath: string, jsonMissingErrorMessage?: string, allMatchedPaths?: string[]): TaskJson { +export function validate(jsonFilePath: string, jsonMissingErrorMessage?: string, allMatchedPaths?: string[], json5?: boolean): TaskJson { trace.debug("Validating task json..."); var jsonMissingErrorMsg: string = jsonMissingErrorMessage || "specified json file does not exist."; exists(jsonFilePath, jsonMissingErrorMsg); + const sourceText = fs.readFileSync(jsonFilePath, "utf8"); + var taskJson; + let pointerContext: any; try { - taskJson = require(jsonFilePath); + const parseErrors: jsonc.ParseError[] = []; + const root = jsonc.parseTree(sourceText, parseErrors, { + allowTrailingComma: !!json5, + disallowComments: !json5, + }); + + if (parseErrors.length > 0 || !root) { + const parseErr: any = new Error("Invalid JSON/JSONC content."); + parseErr.parseErrors = parseErrors; + throw parseErr; + } + + taskJson = jsonc.getNodeValue(root); + pointerContext = { + sourceText, + root, + }; } catch (jsonError) { trace.debug("Invalid task json: %s", jsonError); - throw new Error("Invalid task json: " + jsonError); + const err: any = new Error("Invalid task json: " + jsonError); + let line: number | null = null; + let col: number | null = null; + const parseErrors = jsonError && (jsonError).parseErrors; + if (Array.isArray(parseErrors) && parseErrors.length > 0 && typeof parseErrors[0].offset === "number") { + const loc = offsetToLineCol(sourceText, parseErrors[0].offset); + line = loc.line; + col = loc.col; + } + err.validationIssues = [{ file: jsonFilePath, line, col, message: "Invalid task json." }]; + throw err; } - var issues: string[] = validateTask(jsonFilePath, taskJson); + const issues = validateTask(jsonFilePath, taskJson, pointerContext); if (issues.length > 0) { var output: string = "Invalid task json:"; for (var i = 0; i < issues.length; i++) { - output += "\n\t" + issues[i]; + output += "\n\t" + issues[i].message; } trace.debug(output); - throw new Error(output); + const err: any = new Error(output); + err.validationIssues = issues; + throw err; } trace.debug("Json is valid."); - validateRunner(taskJson, allMatchedPaths); + validateRunner(taskJson, allMatchedPaths, jsonFilePath, pointerContext); return taskJson; } @@ -82,7 +116,7 @@ function countValidRunners(taskData: any): number { * @param taskData the parsed json file * @param allMatchedPaths optional array of all matched task.json paths for backwards compat detection */ -export function validateRunner(taskData: any, allMatchedPaths?: string[]) { +export function validateRunner(taskData: any, allMatchedPaths?: string[], taskPath?: string, pointerContext?: any) { if (countValidRunners(taskData) == 0) { if (allMatchedPaths) { for (const matchedPath of allMatchedPaths) { @@ -102,7 +136,54 @@ export function validateRunner(taskData: any, allMatchedPaths?: string[]) { } } - trace.warn("Task %s@%s is dependent on a task runner that is end-of-life and will be removed in the future. Please visit https://aka.ms/node-runner-guidance to learn how to upgrade the task.", taskData.name, taskData.version?.Major || "?") + const messagePrefix = + "Task " + + (taskData.name || "?") + + "@" + + (taskData.version?.Major || "?") + + " is dependent on a task runner that is end-of-life and will be removed in the future. Please visit https://aka.ms/node-runner-guidance to learn how to upgrade the task."; + + const executionProperties = ['execution', 'prejobexecution', 'postjobexecution']; + const locations: Array<{ executionType: string; runner: string; line: number | null; col: number | null }> = []; + + for (const executionType of executionProperties) { + if (taskData[executionType]) { + Object.keys(taskData[executionType]).forEach(runner => { + if (deprecatedRunners.indexOf(runner) !== -1) { + const runnerLoc = pointerLocation(pointerContext, `/${escapeJsonPointerToken(executionType)}/${escapeJsonPointerToken(runner)}`); + locations.push({ + executionType, + runner, + line: runnerLoc.line, + col: runnerLoc.col, + }); + } + }); + } + } + + if (locations.length === 0) { + if (taskPath) { + console.warn(`${taskPath}(1,1): warning: ${messagePrefix}`); + } else { + console.warn(`warning: ${messagePrefix}`); + } + return; + } + + locations.forEach(location => { + const detail = `${messagePrefix} Deprecated runner '${location.runner}' found in '${location.executionType}'.`; + if (taskPath) { + console.warn( + formatDiagnostic( + { file: taskPath, line: location.line ?? 1, col: location.col ?? 1, message: detail }, + "warning", + ), + ); + } else { + console.warn(formatDiagnostic({ file: null, line: null, col: null, message: detail }, "warning")); + } + }); } } @@ -112,32 +193,38 @@ export function validateRunner(taskData: any, allMatchedPaths?: string[]) { * @param taskData the parsed json file * @return list of issues with the json file */ -export function validateTask(taskPath: string, taskData: any): string[] { +export function validateTask(taskPath: string, taskData: any, pointerContext: any): DiagnosticIssue[] { var vn = taskData.name || taskPath; - var issues: string[] = []; + var issues: DiagnosticIssue[] = []; + + const rootLoc = pointerLocation(pointerContext, ""); + const idLoc = pointerLocation(pointerContext, "/id"); + const nameLoc = pointerLocation(pointerContext, "/name"); + const friendlyNameLoc = pointerLocation(pointerContext, "/friendlyName"); + const instanceNameFormatLoc = pointerLocation(pointerContext, "/instanceNameFormat"); if (!taskData.id || !check.isUUID(taskData.id)) { - issues.push(vn + ": id is a required guid"); + issues.push({ file: taskPath, line: idLoc.line ?? rootLoc.line, col: idLoc.col ?? rootLoc.col, message: "id is a required guid" }); } if (!taskData.name || !check.matches(taskData.name, /^[A-Za-z0-9\-]+$/)) { - issues.push(vn + ": name is a required alphanumeric string"); + issues.push({ file: taskPath, line: nameLoc.line ?? rootLoc.line, col: nameLoc.col ?? rootLoc.col, message: "name is a required alphanumeric string" }); } if (!taskData.friendlyName || !check.isLength(taskData.friendlyName, 1, 40)) { - issues.push(vn + ": friendlyName is a required string <= 40 chars"); + issues.push({ file: taskPath, line: friendlyNameLoc.line ?? rootLoc.line, col: friendlyNameLoc.col ?? rootLoc.col, message: "friendlyName is a required string <= 40 chars" }); } if (!taskData.instanceNameFormat) { - issues.push(vn + ": instanceNameFormat is required"); + issues.push({ file: taskPath, line: instanceNameFormatLoc.line ?? rootLoc.line, col: instanceNameFormatLoc.col ?? rootLoc.col, message: "instanceNameFormat is required" }); } - issues.push(...validateAllExecutionHandlers(taskPath, taskData, vn)); + issues.push(...validateAllExecutionHandlers(taskPath, taskData, vn, pointerContext)); // Fix: Return issues array regardless of whether execution block exists or not // Previously this return was inside the if(taskData.execution) block, causing // tasks without execution configuration to return undefined instead of validation issues - return (issues.length > 0) ? [taskPath, ...issues] : []; + return issues; } /** @@ -147,8 +234,8 @@ export function validateTask(taskPath: string, taskData: any): string[] { * @param vn Name of the task or path * @returns Array of issues found for all handlers */ -function validateAllExecutionHandlers(taskPath: string, taskData: any, vn: string): string[] { - const issues: string[] = []; +function validateAllExecutionHandlers(taskPath: string, taskData: any, vn: string, pointerContext: any): DiagnosticIssue[] { + const issues: DiagnosticIssue[] = []; const executionProperties = ['execution', 'prejobexecution', 'postjobexecution']; const supportedRunners = ["Node", "Node10", "Node16", "Node20_1", "Node24", "PowerShell", "PowerShell3", "Process"]; executionProperties.forEach(executionType => { @@ -156,7 +243,7 @@ function validateAllExecutionHandlers(taskPath: string, taskData: any, vn: strin Object.keys(taskData[executionType]).forEach(runner => { if (supportedRunners.indexOf(runner) === -1) return; const target = taskData[executionType][runner]?.target; - issues.push(...validateExecutionTarget(taskPath, vn, executionType, runner, target)); + issues.push(...validateExecutionTarget(taskPath, vn, executionType, runner, target, pointerContext)); }); } }); @@ -172,10 +259,13 @@ function validateAllExecutionHandlers(taskPath: string, taskData: any, vn: strin * @param target Execution handler's target * @returns Array of issues found for this runner */ -function validateExecutionTarget(taskPath: string, vn: string, executionType: string, runner: string, target: string | undefined): string[] { - const issues: string[] = []; +function validateExecutionTarget(taskPath: string, vn: string, executionType: string, runner: string, target: string | undefined, pointerContext: any): DiagnosticIssue[] { + const issues: DiagnosticIssue[] = []; + const targetPointer = `/${escapeJsonPointerToken(executionType)}/${escapeJsonPointerToken(runner)}/target`; + const targetLoc = pointerLocation(pointerContext, targetPointer); + if (!target) { - issues.push(`${vn}: ${executionType}.${runner}.target is required`); + issues.push({ file: taskPath, line: targetLoc.line, col: targetLoc.col, message: `${executionType}.${runner}.target is required` }); } else { const normalizedTarget = target.replace(/\$\(\s*currentdirectory\s*\)/i, "."); @@ -186,7 +276,7 @@ function validateExecutionTarget(taskPath: string, vn: string, executionType: st // check if the target file exists if (!fs.existsSync(path.join(path.dirname(taskPath), normalizedTarget))) { - issues.push(`${vn}: ${executionType}.${runner}.target references file that does not exist: ${normalizedTarget}`); + issues.push({ file: taskPath, line: targetLoc.line, col: targetLoc.col, message: `${executionType}.${runner}.target references file that does not exist: ${normalizedTarget}` }); } } return issues; diff --git a/docs/extensions.md b/docs/extensions.md index c56698bb..574f5958 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -23,6 +23,7 @@ To learn more about TFX, its pre-reqs and how to install, see the [readme](../RE * `--overrides-file`: Path to a JSON file with overrides. This partial manifest will always take precedence over any values in the manifests. * `--rev-version`: Rev the latest version number of the extension and save the result. * `--bypass-validation`: Bypass local validation. +* `--warnings-as-errors`: Treat task.json validation warnings as errors. * `--publisher`: Use this as the publisher ID instead of what is specified in the manifest. * `--extension-id`: Use this as the extension ID instead of what is specified in the manifest. * `--output-path`: Path to write the VSIX. @@ -30,7 +31,7 @@ To learn more about TFX, its pre-reqs and how to install, see the [readme](../RE ### Examples -#### Package for a different publisher +#### Package for a different publisher ``` tfx extension create --publisher mypublisher --manifest-js myextension.config.js --env mode=production @@ -113,6 +114,38 @@ tfx extension publish --publisher mypublisher --manifest-js myextension.config.j 3. When you run the `publish` command, you will be prompted for a Personal Access Token to authenticate to the Marketplace. For more information about obtaining a Personal Access Token, see [Publish from the command line](https://docs.microsoft.com/azure/devops/extend/publish/command-line?view=vsts). +## Validate an extension + +### Usage + +``` +tfx extension validate +``` + +### Arguments + +You can validate an extension from manifests (similar inputs to `extension create`): + +* `--root`: Root directory. +* `--manifest-js`: Manifest in the form of a standard Node.js CommonJS module with an exported function. If present then the manifests and manifest-globs arguments are ignored. +* `--env`: Environment variables passed to the manifestJs module. +* `--manifests`: List of individual manifest files (space separated). +* `--manifest-globs`: List of globs to find manifests (space separated). +* `--override`: JSON string which is merged into the manifests, overriding any values. +* `--overrides-file`: Path to a JSON file with overrides. This partial manifest will always take precedence over any values in the manifests. +* `--warnings-as-errors`: Treat task.json validation warnings as errors. +* `--publisher`: Use this as the publisher ID instead of what is specified in the manifest. +* `--extension-id`: Use this as the extension ID instead of what is specified in the manifest. +* `--loc-root`: Root of localization hierarchy (see README for more info). + +### Examples + +Validate from manifests: + +``` +tfx extension validate --root . +``` + ## Other commands @@ -120,6 +153,7 @@ tfx extension publish --publisher mypublisher --manifest-js myextension.config.j * `tfx extension show`: Show information about a published extension. * `tfx extension share`: Share an extension with an account. * `tfx extension unshare`: Unshare an extension with an account. +* `tfx extension validate`: Validate an extension from manifests or VSIX without creating/publishing. * `tfx extension isvalid`: Checks if an extension is valid. For more details on a specific command, run: diff --git a/package-lock.json b/package-lock.json index a79b30ab..56982c8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tfx-cli", - "version": "0.23.0", + "version": "0.23.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "tfx-cli", - "version": "0.23.0", + "version": "0.23.1", "license": "MIT", "dependencies": { "app-root-path": "1.0.0", @@ -17,6 +17,7 @@ "glob": "^11.1.0", "jju": "^1.4.0", "json-in-place": "^1.0.1", + "jsonc-parser": "^3.3.1", "jszip": "^3.10.1", "lodash": "^4.17.21", "minimist": "^1.2.6", @@ -2611,6 +2612,12 @@ "integrity": "sha1-vT7V1+Vgudma0iNPKMpwb7N3t9Q=", "license": "ISC" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://pkgs.dev.azure.com/mseng/PipelineTools/_packaging/PipelineTools_PublicPackages/npm/registry/jszip/-/jszip-3.10.1.tgz", @@ -6252,6 +6259,11 @@ "resolved": "https://pkgs.dev.azure.com/mseng/PipelineTools/_packaging/PipelineTools_PublicPackages/npm/registry/json-lexer/-/json-lexer-1.1.1.tgz", "integrity": "sha1-vT7V1+Vgudma0iNPKMpwb7N3t9Q=" }, + "jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" + }, "jszip": { "version": "3.10.1", "resolved": "https://pkgs.dev.azure.com/mseng/PipelineTools/_packaging/PipelineTools_PublicPackages/npm/registry/jszip/-/jszip-3.10.1.tgz", diff --git a/package.json b/package.json index f4d554c3..f3bb31e9 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "glob": "^11.1.0", "jju": "^1.4.0", "json-in-place": "^1.0.1", + "jsonc-parser": "^3.3.1", "jszip": "^3.10.1", "lodash": "^4.17.21", "minimist": "^1.2.6", diff --git a/tests/build-server-integration-tests.ts b/tests/build-server-integration-tests.ts index a2521176..95debfb8 100644 --- a/tests/build-server-integration-tests.ts +++ b/tests/build-server-integration-tests.ts @@ -190,11 +190,15 @@ describe('Build Commands - Server Integration Tests', function() { assert.fail('Should have failed without build ID'); }) .catch((error) => { - const errorOutput = stripColors(error.stderr || error.stdout || ''); + try { + const errorOutput = stripColors(error.stderr || error.stdout || ''); - // Verify specific error message format - assert(errorOutput.includes('error: Error: Missing required value for argument \'buildId\''), 'Should show specific buildId requirement error'); - done(); + // Verify specific error message format + assert(errorOutput.includes('error: Error: Missing required value for argument \'buildId\''), 'Should show specific buildId requirement error'); + done(); + } catch (assertionError) { + done(assertionError); + } }); }); }); @@ -256,11 +260,15 @@ describe('Build Commands - Server Integration Tests', function() { assert.fail('Should have failed without definition'); }) .catch((error) => { - const errorOutput = stripColors(error.stderr || error.stdout || ''); + try { + const errorOutput = stripColors(error.stderr || error.stdout || ''); - // Verify specific error message format - assert(errorOutput.includes('error: Error: No definition found with name null'), 'Should show specific definition requirement error'); - done(); + // Verify specific error message format + assert(errorOutput.includes('error: Error: No definition found with name null'), 'Should show specific definition requirement error'); + done(); + } catch (assertionError) { + done(assertionError); + } }); }); }); @@ -296,10 +304,14 @@ describe('Build Commands - Server Integration Tests', function() { assert.fail('Should have failed without authentication'); }) .catch((error) => { - const errorOutput = stripColors(error.stderr || error.stdout || ''); - // Check for specific error message about missing token - assert(errorOutput.includes("error: Error: Missing required value for argument 'token'."), 'Should show missing token error'); - done(); + try { + const errorOutput = stripColors(error.stderr || error.stdout || ''); + // Check for specific error message about missing token + assert(errorOutput.includes("error: Error: Missing required value for argument 'token'."), 'Should show missing token error'); + done(); + } catch (assertionError) { + done(assertionError); + } }); }); }); @@ -339,10 +351,14 @@ describe('Build Commands - Server Integration Tests', function() { } catch (e) { // Ignore cleanup errors } - const errorOutput = stripColors(error.stderr || error.stdout || ''); - // Should fail with specific error message - assert(errorOutput.includes('error: Error: no task.json in specified directory'), 'Should indicate task.json is missing'); - done(); + try { + const errorOutput = stripColors(error.stderr || error.stdout || ''); + // Should fail with specific error message + assert(errorOutput.includes('error: Error: no task.json in specified directory'), 'Should indicate task.json is missing'); + done(); + } catch (assertionError) { + done(assertionError); + } }); }); @@ -374,13 +390,16 @@ describe('Build Commands - Server Integration Tests', function() { done(); }) .catch((error) => { - const errorOutput = stripColors(error.stderr || error.stdout || ''); - // Should fail with specific error messages for invalid fields - assert(errorOutput.includes('error: Error: Invalid task json:'), 'Should indicate invalid task json'); - assert(errorOutput.includes('id is a required guid'), 'Should indicate missing id'); - assert(errorOutput.includes('name is a required alphanumeric string'), 'Should indicate missing name'); - assert(errorOutput.includes('friendlyName is a required string <= 40 chars'), 'Should indicate missing friendlyName'); - done(); + try { + const errorOutput = stripColors(error.stderr || error.stdout || ''); + // Should fail with specific error messages for invalid fields + assert(errorOutput.includes('id is a required guid'), 'Should indicate missing id'); + assert(errorOutput.includes('name is a required alphanumeric string'), 'Should indicate missing name'); + assert(errorOutput.includes('friendlyName is a required string <= 40 chars'), 'Should indicate missing friendlyName'); + done(); + } catch (assertionError) { + done(assertionError); + } }); }); @@ -392,9 +411,13 @@ describe('Build Commands - Server Integration Tests', function() { assert.fail('Should have failed without task path'); }) .catch((error) => { - const errorOutput = stripColors(error.stderr || error.stdout || ''); - assert(errorOutput.includes('error: Error: You must specify either --task-path or --task-zip-path.'), 'Should indicate task path is required'); - done(); + try { + const errorOutput = stripColors(error.stderr || error.stdout || ''); + assert(errorOutput.includes('error: Error: You must specify either --task-path or --task-zip-path.'), 'Should indicate task path is required'); + done(); + } catch (assertionError) { + done(assertionError); + } }); }); }); @@ -424,9 +447,13 @@ describe('Build Commands - Server Integration Tests', function() { assert.fail('Should have failed without task ID'); }) .catch((error) => { - const errorOutput = stripColors(error.stderr || error.stdout || ''); - assert(errorOutput.includes("error: Error: Missing required value for argument 'taskId'."), 'Should indicate task ID is required'); - done(); + try { + const errorOutput = stripColors(error.stderr || error.stdout || ''); + assert(errorOutput.includes("error: Error: Missing required value for argument 'taskId'."), 'Should indicate task ID is required'); + done(); + } catch (assertionError) { + done(assertionError); + } }); }); @@ -438,9 +465,13 @@ describe('Build Commands - Server Integration Tests', function() { done(); }) .catch((error) => { - const errorOutput = stripColors(error.stderr || error.stdout || ''); - assert(errorOutput.includes('error: Error: No task found with provided ID: invalid-task-id'), 'Should indicate no task found for invalid ID'); - done(); + try { + const errorOutput = stripColors(error.stderr || error.stdout || ''); + assert(errorOutput.includes('error: Error: No task found with provided ID: invalid-task-id'), 'Should indicate no task found for invalid ID'); + done(); + } catch (assertionError) { + done(assertionError); + } }); }); }); @@ -563,9 +594,13 @@ describe('Build Commands - Server Integration Tests', function() { assert.fail('Should have failed without project'); }) .catch((error) => { - const errorOutput = stripColors(error.stderr || error.stdout || ''); - assert(errorOutput.includes("error: Error: Missing required value for argument 'project'."), 'Should indicate project is required'); - done(); + try { + const errorOutput = stripColors(error.stderr || error.stdout || ''); + assert(errorOutput.includes("error: Error: Missing required value for argument 'project'."), 'Should indicate project is required'); + done(); + } catch (assertionError) { + done(assertionError); + } }); }); @@ -577,9 +612,13 @@ describe('Build Commands - Server Integration Tests', function() { done(); }) .catch((error) => { - const errorOutput = stripColors(error.stderr || error.stdout || ''); - assert(errorOutput.includes("error: Error: Unsupported auth type. Currently, 'pat' and 'basic' auth are supported."), 'Should indicate unsupported auth type'); - done(); + try { + const errorOutput = stripColors(error.stderr || error.stdout || ''); + assert(errorOutput.includes("error: Error: Unsupported auth type. Currently, 'pat' and 'basic' auth are supported."), 'Should indicate unsupported auth type'); + done(); + } catch (assertionError) { + done(assertionError); + } }); }); }); diff --git a/tests/extension-local-tests.ts b/tests/extension-local-tests.ts index 00a76c30..bb094086 100644 --- a/tests/extension-local-tests.ts +++ b/tests/extension-local-tests.ts @@ -76,13 +76,14 @@ describe('Extension Commands - Local Tests', function() { }); describe('Command Help and Hierarchy', function() { - + it('should display extension command group help', function(done) { execAsyncWithLogging(`node "${tfxPath}" extension --help`, 'extension --help') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); assert(cleanOutput.includes('Available commands and command groups in tfx / extension'), 'Should show extension command hierarchy'); assert(cleanOutput.includes('create:'), 'Should list create command'); + assert(cleanOutput.includes('validate:'), 'Should list validate command'); assert(cleanOutput.includes('publish:'), 'Should list publish command'); assert(cleanOutput.includes('show:'), 'Should list show command'); assert(cleanOutput.includes('install:'), 'Should list install command'); @@ -127,6 +128,19 @@ describe('Extension Commands - Local Tests', function() { .catch(done); }); + it('should display validate command help', function(done) { + execAsyncWithLogging(`node "${tfxPath}" extension validate --help`, 'extension validate --help') + .then(({ stdout }) => { + const cleanOutput = stripColors(stdout); + assert(cleanOutput.includes('Validate an extension from manifests without packaging or publishing'), 'Should show validate command description'); + assert(!cleanOutput.includes('--vsix'), 'Should not show vsix argument'); + assert(cleanOutput.includes('--warnings-as-errors'), 'Should show warnings-as-errors argument'); + assert(cleanOutput.includes('--manifest-js') || cleanOutput.includes('--manifests'), 'Should show manifest arguments'); + done(); + }) + .catch(done); + }); + it('should display install command help', function(done) { execAsyncWithLogging(`node "${tfxPath}" extension install --help`, 'extension install --help') .then(({ stdout }) => { @@ -176,13 +190,13 @@ describe('Extension Commands - Local Tests', function() { it('should create extension from basic sample', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'test-extension.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}"`, 'extension create basic sample') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); assert(cleanOutput.includes('Completed operation: create extension'), 'Should indicate successful creation'); assert(fs.existsSync(outputPath), 'Should create .vsix file'); - + const stats = fs.statSync(outputPath); assert(stats.size > 0, 'Created .vsix file should not be empty'); done(); @@ -195,7 +209,7 @@ describe('Extension Commands - Local Tests', function() { if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir); } - + const outputPath = path.join(__dirname, 'temp-extension-create.vsix'); execAsyncWithLogging(`node "${tfxPath}" extension create --root "${tempDir}" --output-path "${outputPath}" --no-prompt`, 'extension create missing manifest') .then(() => { @@ -204,7 +218,7 @@ describe('Extension Commands - Local Tests', function() { .catch((error) => { const cleanOutput = stripColors(error.stderr || error.stdout || error.message); assert(cleanOutput.includes('ENOENT') || cleanOutput.includes('vss-extension.json') || cleanOutput.includes('manifest') || cleanOutput.includes('no manifests found'), 'Should mention missing manifest file'); - + // Cleanup try { if (fs.existsSync(outputPath)) { @@ -218,16 +232,214 @@ describe('Extension Commands - Local Tests', function() { }); }); + describe('Extension Validation - Basic Operations', function() { + it('should validate extension from manifest inputs without creating a vsix', function(done) { + const basicExtensionPath = path.join(samplesPath, 'basic-extension'); + const outputPath = path.join(basicExtensionPath, 'validate-manifest-should-not-exist.vsix'); + + execAsyncWithLogging(`node "${tfxPath}" extension validate --root "${basicExtensionPath}" --output-path "${outputPath}"`, 'extension validate from manifest') + .then(({ stdout }) => { + const cleanOutput = stripColors(stdout); + assert(cleanOutput.includes('Completed operation: validate extension'), 'Should indicate validate operation completed'); + assert(cleanOutput.includes('Validation: success'), 'Should indicate validation success'); + assert(!fs.existsSync(outputPath), 'Validate should not create a .vsix file'); + done(); + }) + .catch(done) + .finally(() => { + try { + if (fs.existsSync(outputPath)) { + fs.unlinkSync(outputPath); + } + } catch (e) { + // Ignore cleanup errors + } + }); + }); + + it('should reject --vsix for validate command', function(done) { + const basicExtensionPath = path.join(samplesPath, 'basic-extension'); + const vsixPath = path.join(basicExtensionPath, 'validate-input.vsix'); + + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${vsixPath}"`, 'extension create for validate-vsix') + .then(() => { + return execAsyncWithLogging(`node "${tfxPath}" extension validate --vsix "${vsixPath}"`, 'extension validate --vsix'); + }) + .then(() => { + done(new Error('Validate should fail when --vsix is provided')); + }) + .catch((error) => { + const cleanOutput = stripColors(error.stderr || error.stdout || error.message); + assert(cleanOutput.includes('--vsix') && cleanOutput.includes('not supported'), 'Should indicate that --vsix is not supported'); + done(); + }) + .finally(() => { + try { + if (fs.existsSync(vsixPath)) { + fs.unlinkSync(vsixPath); + } + } catch (e) { + // Ignore cleanup errors + } + }); + }); + + it('should treat task.json warnings as errors when --warnings-as-errors is set', function(done) { + const invalidTaskExtensionPath = path.join(samplesPath, 'invalid-task-extension'); + + execAsyncWithLogging( + `node "${tfxPath}" extension validate --root "${invalidTaskExtensionPath}" --warnings-as-errors --no-prompt`, + 'extension validate --warnings-as-errors', + ) + .then(({ stdout }) => { + const cleanOutput = stripColors(stdout); + assert(cleanOutput.includes('Validation: failed'), 'Should fail when warnings are treated as errors'); + assert(cleanOutput.includes('id is a required guid'), 'Should surface task.json validation details as errors'); + done(); + }) + .catch(done); + }); + + it('should output structured diagnostics in json mode', function(done) { + const invalidTaskExtensionPath = path.join(samplesPath, 'invalid-task-extension'); + + execAsyncWithLogging( + `node "${tfxPath}" extension validate --root "${invalidTaskExtensionPath}" --json --warnings-as-errors --no-prompt`, + 'extension validate --json --warnings-as-errors', + ) + .then(({ stdout }) => { + const result = JSON.parse(stdout); + assert(result && result.status === 'error', 'Should return error status in json mode'); + assert(Array.isArray(result.issues) && result.issues.length > 0, 'Should include issues'); + + const firstIssue = result.issues[0]; + assert(Object.prototype.hasOwnProperty.call(firstIssue, 'file'), 'Issue should include file'); + assert(Object.prototype.hasOwnProperty.call(firstIssue, 'line'), 'Issue should include line'); + assert(Object.prototype.hasOwnProperty.call(firstIssue, 'col'), 'Issue should include col'); + assert(Object.prototype.hasOwnProperty.call(firstIssue, 'message'), 'Issue should include message'); + + const markerIssue = result.issues.find(issue => issue.message === 'Invalid task json:' || issue.message === 'task.json'); + assert(!markerIssue, 'Should not include marker-only issues in structured output'); + + const idIssue = result.issues.find(issue => issue.message.indexOf('id is a required guid') >= 0); + assert(idIssue, 'Should include id validation issue'); + assert(idIssue.line && idIssue.line > 0, 'Issue should include line when available'); + assert(idIssue.col && idIssue.col > 0, 'Issue should include col when available'); + + done(); + }) + .catch(done); + }); + + it('should include manifest file location for invalid target diagnostics', function(done) { + const invalidExtensionPath = path.join(samplesPath, 'invalid-extension'); + + execAsyncWithLogging( + `node "${tfxPath}" extension validate --root "${invalidExtensionPath}" --json --no-prompt`, + 'extension validate invalid target json', + ) + .then(({ stdout }) => { + const result = JSON.parse(stdout); + assert(result && result.status === 'error', 'Should return error status in json mode'); + const targetIssue = result.issues.find(issue => issue.message.indexOf('not a recognized target') >= 0); + assert(targetIssue, 'Should include invalid target issue'); + assert(targetIssue.file && targetIssue.file.endsWith('vss-extension.json'), 'Issue should include manifest file'); + assert(targetIssue.line && targetIssue.line > 0, 'Issue should include line when available'); + assert(targetIssue.col && targetIssue.col > 0, 'Issue should include col when available'); + done(); + }) + .catch(done); + }); + + it('should report missing task name when name is omitted', function(done) { + const tempRoot = path.join(__dirname, '../temp-extensions/missing-name-task-extension'); + const taskDir = path.join(tempRoot, 'MissingNameTask'); + const manifestPath = path.join(tempRoot, 'vss-extension.json'); + const taskJsonPath = path.join(taskDir, 'task.json'); + const taskTargetPath = path.join(taskDir, 'index.js'); + + try { + if (!fs.existsSync(path.dirname(tempRoot))) { + fs.mkdirSync(path.dirname(tempRoot)); + } + if (!fs.existsSync(tempRoot)) { + fs.mkdirSync(tempRoot); + } + if (!fs.existsSync(taskDir)) { + fs.mkdirSync(taskDir); + } + + const manifest = { + manifestVersion: 1, + id: 'missing-name-task-extension', + publisher: 'fabrikam', + version: '1.0.0', + name: 'Missing Name Task Extension', + targets: [{ id: 'Microsoft.VisualStudio.Services' }], + contributions: [ + { + id: 'missing-name-task-contribution', + type: 'ms.vss-distributed-task.task', + targets: ['ms.vss-distributed-task.tasks'], + properties: { + name: 'MissingNameTask', + }, + }, + ], + }; + + const taskJson = { + id: '11111111-1111-1111-1111-111111111111', + friendlyName: 'Sample Friendly Name', + instanceNameFormat: 'Do work', + helpMarkDown: 'Help', + category: 'Utility', + author: 'Microsoft', + version: { Major: 1, Minor: 0, Patch: 0 }, + inputs: [], + execution: { + Node20_1: { + target: 'index.js', + }, + }, + }; + + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + fs.writeFileSync(taskJsonPath, JSON.stringify(taskJson, null, 2)); + fs.writeFileSync(taskTargetPath, 'console.log("ok");'); + } catch (setupError) { + done(setupError); + return; + } + + execAsyncWithLogging( + `node "${tfxPath}" extension validate --root "${tempRoot}" --json --warnings-as-errors --no-prompt`, + 'extension validate missing task name', + ) + .then(({ stdout }) => { + const result = JSON.parse(stdout); + assert(result && result.status === 'error', 'Should return error status in json mode'); + assert(Array.isArray(result.issues) && result.issues.length > 0, 'Should include issues'); + + const nameIssue = result.issues.find(issue => issue.message.indexOf('name is a required alphanumeric string') >= 0); + assert(nameIssue, 'Should include missing name validation issue'); + assert(nameIssue.file && nameIssue.file.endsWith('task.json'), 'Issue should point to task.json file'); + done(); + }) + .catch(done); + }); + }); + describe('Extension Creation - Advanced Features', function() { it('should handle --override parameter', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'override-test.vsix'); const overrideFilePath = path.join(basicExtensionPath, 'test-overrides.json'); - + // Create temporary overrides file fs.writeFileSync(overrideFilePath, JSON.stringify({ version: "2.0.0" }, null, 2)); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}" --overrides-file "${overrideFilePath}"`, 'extension create with overrides') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); @@ -256,7 +468,7 @@ describe('Extension Commands - Local Tests', function() { it('should handle --rev-version parameter', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'rev-version-test.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}" --rev-version`, 'extension create --rev-version') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); @@ -270,7 +482,7 @@ describe('Extension Commands - Local Tests', function() { it('should handle --bypass-validation parameter', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'bypass-validation-test.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}" --bypass-validation`, 'extension create --bypass-validation') .then(({ stdout }) => { // With bypass validation, it might still fail due to other issues, but validation should be skipped @@ -289,7 +501,7 @@ describe('Extension Commands - Local Tests', function() { }); describe('Extension Global Arguments', function() { - + it('should handle --no-color argument', function(done) { execAsyncWithLogging(`node "${tfxPath}" extension --help --no-color`, 'extension --help --no-color') .then(({ stdout }) => { @@ -305,7 +517,7 @@ describe('Extension Commands - Local Tests', function() { it('should handle --trace-level argument', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'trace-test.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}" --trace-level debug`, 'extension create --trace-level debug') .then(({ stdout, stderr }) => { const cleanOutput = stripColors(stdout + stderr); @@ -330,15 +542,15 @@ describe('Extension Commands - Local Tests', function() { }); describe('Extension File Path Handling', function() { - + it('should handle relative paths', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'relative-test.vsix'); - + // Change to the extension directory and use relative paths const oldCwd = process.cwd(); process.chdir(basicExtensionPath); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root . --output-path relative-test.vsix`, 'extension create relative path') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); @@ -356,7 +568,7 @@ describe('Extension Commands - Local Tests', function() { it('should handle absolute paths', function(done) { const basicExtensionPath = path.resolve(samplesPath, 'basic-extension'); const outputPath = path.resolve(basicExtensionPath, 'absolute-test.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}"`, 'extension create absolute path') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); @@ -369,11 +581,11 @@ describe('Extension Commands - Local Tests', function() { }); describe('Extension Manifest Variations', function() { - + it('should handle manifest-globs parameter', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'manifest-globs-test.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}" --manifest-globs "vss-extension.json"`, 'extension create --manifest-globs') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); @@ -657,13 +869,13 @@ describe('Extension Commands - Local Tests', function() { } const outputPath = path.join(complexExtensionPath, 'complex-extension.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${complexExtensionPath}" --output-path "${outputPath}"`, 'extension create complex') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); assert(cleanOutput.includes('Completed operation: create extension'), 'Should create complex extension'); assert(fs.existsSync(outputPath), 'Should create .vsix file for complex extension'); - + const stats = fs.statSync(outputPath); assert(stats.size > 1000, 'Complex extension should be reasonably sized'); done(); @@ -674,7 +886,7 @@ describe('Extension Commands - Local Tests', function() { it('should override publisher in manifest', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'publisher-override.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}" --publisher "test-publisher"`, 'extension create --publisher') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); @@ -688,7 +900,7 @@ describe('Extension Commands - Local Tests', function() { it('should override extension-id in manifest', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'extension-id-override.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}" --extension-id "test-extension-id"`, 'extension create --extension-id') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); @@ -702,7 +914,7 @@ describe('Extension Commands - Local Tests', function() { it('should support JSON output format for create command', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'json-output-test.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}" --json`, 'extension create --json') .then(({ stdout }) => { // With --json flag, output might be JSON formatted @@ -713,6 +925,81 @@ describe('Extension Commands - Local Tests', function() { }) .catch(done); }); + + it('should output structured JSON errors for create command failures', function(done) { + const tempRoot = path.join(__dirname, '../temp-extensions/create-json-error'); + const partialsDir = path.join(tempRoot, 'partials'); + const manifestMainPath = path.join(tempRoot, 'vss-extension.json'); + const manifestBadPath = path.join(partialsDir, 'bad-target.json'); + + try { + if (!fs.existsSync(path.dirname(tempRoot))) { + fs.mkdirSync(path.dirname(tempRoot)); + } + if (!fs.existsSync(tempRoot)) { + fs.mkdirSync(tempRoot); + } + if (!fs.existsSync(partialsDir)) { + fs.mkdirSync(partialsDir); + } + + fs.writeFileSync( + manifestMainPath, + JSON.stringify( + { + manifestVersion: 1, + id: 'create-json-error-extension', + publisher: 'fabrikam', + version: '1.0.0', + name: 'Create JSON Error Extension', + targets: [{ id: 'Microsoft.VisualStudio.Services' }], + }, + null, + 2, + ), + ); + fs.writeFileSync( + manifestBadPath, + JSON.stringify( + { + targets: [{ id: 'Invalid.Target' }], + }, + null, + 2, + ), + ); + } catch (setupError) { + done(setupError); + return; + } + + execAsyncWithLogging( + `node "${tfxPath}" extension create --root "${tempRoot}" --manifest-globs vss-extension.json partials/*.json --output-path "${path.join(tempRoot, 'out.vsix')}" --json --no-prompt`, + 'extension create --json failure', + ) + .then(() => { + done(new Error('Create should fail for invalid target')); + }) + .catch((error) => { + const jsonText = error.stdout || error.stderr || error.message; + let payload: any; + try { + payload = JSON.parse(jsonText); + } catch (parseError) { + done(new Error('Expected JSON error output from create --json')); + return; + } + + assert(payload.status === 'error', 'Should emit error status'); + assert(Array.isArray(payload.issues) && payload.issues.length > 0, 'Should include structured issues'); + const targetIssue = payload.issues.find(issue => issue.message.indexOf('not a recognized target') >= 0); + assert(targetIssue, 'Should include invalid target issue'); + assert(targetIssue.file && targetIssue.file.endsWith('bad-target.json'), 'Issue should point to offending manifest'); + assert(targetIssue.line && targetIssue.line > 0, 'Issue should include line'); + assert(targetIssue.col && targetIssue.col > 0, 'Issue should include col'); + done(); + }); + }); }); describe('Extension Creation - File System Edge Cases', function() { @@ -768,7 +1055,7 @@ describe('Extension Commands - Local Tests', function() { it('should handle extension with missing files referenced in manifest', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'missing-files-test.vsix'); - + // This should still create the extension but might show warnings execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}"`, 'extension create missing files in manifest') .then(({ stdout }) => { @@ -797,7 +1084,7 @@ describe('Extension Commands - Local Tests', function() { } const outputPath = path.join(taskExtensionPath, 'valid-task-extension.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${taskExtensionPath}" --output-path "${outputPath}"`, 'extension create task extension') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); @@ -817,7 +1104,7 @@ describe('Extension Commands - Local Tests', function() { } const outputPath = path.join(taskExtensionPath, 'validated-task.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${taskExtensionPath}" --output-path "${outputPath}"`, 'extension create validated task') .then(({ stdout }) => { // Should validate task.json files without errors @@ -837,7 +1124,7 @@ describe('Extension Commands - Local Tests', function() { } const outputPath = path.join(taskExtensionPath, 'deprecated-runner.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${taskExtensionPath}" --output-path "${outputPath}"`, 'extension create deprecated runner') .then(({ stdout }) => { // Should still create extension despite warnings @@ -856,7 +1143,7 @@ describe('Extension Commands - Local Tests', function() { } const outputPath = path.join(taskExtensionPath, 'versioned-tasks.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${taskExtensionPath}" --output-path "${outputPath}"`, 'extension create versioned tasks') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); @@ -870,7 +1157,7 @@ describe('Extension Commands - Local Tests', function() { it('should warn about invalid task.json but still create extension', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'invalid-task-test.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}"`, 'extension create invalid task') .then(({ stdout }) => { // Should create extension despite task validation warnings @@ -883,7 +1170,7 @@ describe('Extension Commands - Local Tests', function() { it('should warn about missing task.json file', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'missing-task-json.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}"`, 'extension create missing task json') .then(({ stdout }) => { // Should create extension despite missing task files @@ -896,7 +1183,7 @@ describe('Extension Commands - Local Tests', function() { it('should warn about missing execution target file', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'missing-execution-file.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}"`, 'extension create missing execution file') .then(({ stdout }) => { // Should create extension despite warnings about missing execution files @@ -909,7 +1196,7 @@ describe('Extension Commands - Local Tests', function() { it('should warn about invalid task name format', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'invalid-task-name.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}"`, 'extension create invalid task name') .then(({ stdout }) => { // Should create extension despite task name validation warnings @@ -922,7 +1209,7 @@ describe('Extension Commands - Local Tests', function() { it('should warn about friendly name length', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'long-name-extension.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}" --no-prompt`, 'extension create long name') .then(({ stdout, stderr }) => { // Should create extension despite friendly name warnings @@ -941,7 +1228,7 @@ describe('Extension Commands - Local Tests', function() { } const outputPath = path.join(taskExtensionPath, 'contributions-test.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${taskExtensionPath}" --output-path "${outputPath}"`, 'extension create contributions test') .then(({ stdout }) => { // Should validate task directory structure @@ -954,7 +1241,7 @@ describe('Extension Commands - Local Tests', function() { it('should handle extensions without task contributions', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'no-tasks-extension.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}"`, 'extension create no tasks') .then(({ stdout }) => { const cleanOutput = stripColors(stdout); @@ -968,7 +1255,7 @@ describe('Extension Commands - Local Tests', function() { it('should validate required task fields', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'task-fields-test.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}"`, 'extension create task fields') .then(({ stdout }) => { // Should validate task field requirements @@ -981,7 +1268,7 @@ describe('Extension Commands - Local Tests', function() { it('should validate task inputs structure', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'task-inputs-test.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}"`, 'extension create task inputs') .then(({ stdout }) => { // Should validate task inputs structure @@ -994,7 +1281,7 @@ describe('Extension Commands - Local Tests', function() { it('should validate execution targets exist', function(done) { const basicExtensionPath = path.join(samplesPath, 'basic-extension'); const outputPath = path.join(basicExtensionPath, 'execution-targets-test.vsix'); - + execAsyncWithLogging(`node "${tfxPath}" extension create --root "${basicExtensionPath}" --output-path "${outputPath}"`, 'extension create execution targets') .then(({ stdout }) => { // Should validate execution target file existence