From 2ba72cd14253500bbb747b5f01e72dd03fbafcb0 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 3 Sep 2026 19:14:23 +1000 Subject: [PATCH] Fix external canvas extension validation (#2928) * fix(intake): accept nested canvas extension entry points Allow canvas entry points at any depth under the com.github.copilot namespace, including materialized extensions/ layouts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3eb2554c-9e4a-408c-92f5-36306c99d33b * fix(intake): require named canvas extension directories Require canvas entry points at com.github.copilot//extension.mjs and reject flat or deeper layouts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3eb2554c-9e4a-408c-92f5-36306c99d33b * fix: align external canvas plugin layout Require canvas extensions under com.github.copilot/extensions//extension.mjs, matching github-app discovery and reject legacy layouts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3eb2554c-9e4a-408c-92f5-36306c99d33b * test: cover canvas logo validation, fix directory-mismatch message Add intake regression tests for a missing/malformed com.github.copilot namespace and an incorrect logo path. Fix the quality gate to report a file-vs-directory mismatch instead of a generic missing-entry-point message when extension.mjs exists but is a directory. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3eb2554c-9e4a-408c-92f5-36306c99d33b --------- Copilot-Session: 3eb2554c-9e4a-408c-92f5-36306c99d33b --- eng/external-plugin-intake.mjs | 84 +++++------ eng/external-plugin-intake.test.mjs | 156 ++++++++++++++++----- eng/external-plugin-quality-gates.mjs | 39 +++--- eng/external-plugin-quality-gates.test.mjs | 63 +++++---- 4 files changed, 211 insertions(+), 131 deletions(-) diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs index de15571b..3e04f428 100644 --- a/eng/external-plugin-intake.mjs +++ b/eng/external-plugin-intake.mjs @@ -61,6 +61,11 @@ const LEGACY_FIELD_TITLES = Object.freeze({ }); const EXTERNAL_CANVAS_KEYWORD = "canvas"; const EXTERNAL_CANVAS_PREVIEW_PATH = "assets/preview.png"; +// External plugins target the Copilot app client harness directly, so their canvas extension +// files live under the client's own namespace directory rather than the repository-local +// "com.github.awesome-copilot" namespace used to materialize plugins that live in this repo. +const COPILOT_CLIENT_NAMESPACE = "com.github.copilot"; +const COPILOT_EXTENSIONS_DIRECTORY = "extensions"; const HOMEPAGE_FETCH_TIMEOUT_MS = 10_000; const HOMEPAGE_MAX_BYTES = 512_000; const HOMEPAGE_MAX_REDIRECTS = 5; @@ -744,14 +749,14 @@ async function resolveDirectoryTreeSha(repo, treeish, segments, token) { return { status: "found", treeSha: currentTreeish }; } -// Inspect the (recursively fetched) "extensions" subtree for the plugin's canvas extension -// entry point. Paths are relative to "extensions/", so the flat form is "extension.mjs" and a -// nested form is "/extension.mjs". Scoping the recursive fetch to this subtree keeps the -// lookup complete without depending on the size of the rest of the repository. +// Inspect the (recursively fetched) "com.github.copilot" subtree for the plugin's canvas +// extension entry point. Paths are relative to "com.github.copilot/" and must use the +// "extensions//extension.mjs" layout used by the Copilot app. Scoping the +// recursive fetch to this subtree keeps the lookup complete without depending on the size of +// the rest of the repository. function analyzeCanvasExtensionSubtree(subtreeEntries) { - let flatIsBlob = false; - let flatIsTree = false; let nestedEntryPath = null; + let nestedEntryIsNotFile = false; for (const entry of subtreeEntries) { const entryPath = entry?.path; @@ -759,28 +764,20 @@ function analyzeCanvasExtensionSubtree(subtreeEntries) { continue; } - if (entryPath === "extension.mjs") { - if (entry.type === "blob") { - flatIsBlob = true; - } else if (entry.type === "tree") { - flatIsTree = true; - } - continue; - } - const segments = entryPath.split("/"); - if (segments.length === 2 && segments[1] === "extension.mjs" && entry.type === "blob") { - nestedEntryPath = nestedEntryPath ?? `extensions/${entryPath}`; + if (segments.length === 3 && segments[0] === COPILOT_EXTENSIONS_DIRECTORY && segments[2] === "extension.mjs") { + if (entry.type === "blob") { + nestedEntryPath = nestedEntryPath ?? `${COPILOT_CLIENT_NAMESPACE}/${entryPath}`; + } else { + nestedEntryIsNotFile = true; + } } } - if (flatIsBlob) { - return { status: "found", entryPath: "extensions/extension.mjs" }; - } if (nestedEntryPath) { return { status: "found", entryPath: nestedEntryPath }; } - if (flatIsTree) { + if (nestedEntryIsNotFile) { return { status: "notFile" }; } return { status: "notFound" }; @@ -859,46 +856,39 @@ export async function validateCanvasPluginMetadata(plugin, errors, warnings, tok return; } - if (manifest.logo !== EXTERNAL_CANVAS_PREVIEW_PATH) { + const copilotNamespace = manifest.extensions?.[COPILOT_CLIENT_NAMESPACE]; + if (!copilotNamespace || typeof copilotNamespace !== "object" || Array.isArray(copilotNamespace)) { errors.push( - `submission: plugins tagged with "canvas" must set "logo" to "${EXTERNAL_CANVAS_PREVIEW_PATH}" in "${manifestPath}"`, + `submission: plugins tagged with "canvas" must set "extensions.${COPILOT_CLIENT_NAMESPACE}.logo" to "${EXTERNAL_CANVAS_PREVIEW_PATH}" in "${manifestPath}"`, ); - } - - if (manifest.extenions !== undefined) { + } else if (copilotNamespace.logo !== EXTERNAL_CANVAS_PREVIEW_PATH) { errors.push( - `submission: plugins tagged with "canvas" must use "extensions" (found misspelled key "extenions") in "${manifestPath}"`, - ); - } - - if (manifest.extensions !== undefined && manifest.extensions !== "extensions") { - errors.push( - `submission: plugins tagged with "canvas" may omit "extensions", but if provided it must be "extensions" in "${manifestPath}"`, + `submission: plugins tagged with "canvas" must set "logo" to "${EXTERNAL_CANVAS_PREVIEW_PATH}" in "extensions.${COPILOT_CLIENT_NAMESPACE}" of "${manifestPath}"`, ); } const unverifiableEntryPointWarning = `submission: could not verify the canvas extension entry point in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`; - const extensionsSegments = [...(pluginRoot ? pluginRoot.split("/") : []), "extensions"]; - const extensionsTree = await resolveDirectoryTreeSha( + const namespaceSegments = [...(pluginRoot ? pluginRoot.split("/") : []), COPILOT_CLIENT_NAMESPACE]; + const namespaceTree = await resolveDirectoryTreeSha( repo, normalizeTreeish(releaseLocator), - extensionsSegments, + namespaceSegments, token, ); - if (extensionsTree.status === "apiError") { + if (namespaceTree.status === "apiError") { warnings.push(unverifiableEntryPointWarning); - } else if (extensionsTree.status === "missing") { + } else if (namespaceTree.status === "missing") { errors.push( - `submission: plugins tagged with "canvas" must include an "extensions" directory at ${releaseLocatorDescription}`, + `submission: plugins tagged with "canvas" must include a "${COPILOT_CLIENT_NAMESPACE}" directory at ${releaseLocatorDescription}`, ); - } else if (extensionsTree.status === "notDirectory") { + } else if (namespaceTree.status === "notDirectory") { errors.push( - `submission: "extensions" must be a directory in ${releaseLocatorDescription}`, + `submission: "${COPILOT_CLIENT_NAMESPACE}" must be a directory in ${releaseLocatorDescription}`, ); } else { const subtreeResponse = await fetchGitHubJson( - buildGitTreePath(repo, extensionsTree.treeSha, { recursive: true }), + buildGitTreePath(repo, namespaceTree.treeSha, { recursive: true }), token, ); if (subtreeResponse.kind !== "found" || !Array.isArray(subtreeResponse.data?.tree)) { @@ -906,19 +896,19 @@ export async function validateCanvasPluginMetadata(plugin, errors, warnings, tok } else { const canvasStructure = analyzeCanvasExtensionSubtree(subtreeResponse.data.tree); if (canvasStructure.status === "found") { - // Entry point located (flat or nested); nothing to report. + // Entry point located in the required named extension directory. } else if (subtreeResponse.data.truncated) { - // Absence is only inconclusive if the (already extensions-scoped) subtree itself is - // truncated, which would take an implausibly large extensions directory; flag it as + // Absence is only inconclusive if the (already namespace-scoped) subtree itself is + // truncated, which would take an implausibly large extension directory; flag it as // unverifiable rather than falsely rejecting. warnings.push(unverifiableEntryPointWarning); } else if (canvasStructure.status === "notFile") { errors.push( - `submission: "extensions/extension.mjs" must be a file in ${releaseLocatorDescription}`, + `submission: "${COPILOT_CLIENT_NAMESPACE}/${COPILOT_EXTENSIONS_DIRECTORY}//extension.mjs" must be a file in ${releaseLocatorDescription}`, ); } else { errors.push( - `submission: plugins tagged with "canvas" must include a canvas extension entry point at "extensions/extension.mjs" or "extensions//extension.mjs" at ${releaseLocatorDescription}`, + `submission: plugins tagged with "canvas" must include a canvas extension entry point at "${COPILOT_CLIENT_NAMESPACE}/${COPILOT_EXTENSIONS_DIRECTORY}//extension.mjs" at ${releaseLocatorDescription}`, ); } } diff --git a/eng/external-plugin-intake.test.mjs b/eng/external-plugin-intake.test.mjs index 0e9cff41..b55b6956 100644 --- a/eng/external-plugin-intake.test.mjs +++ b/eng/external-plugin-intake.test.mjs @@ -71,7 +71,11 @@ const canvasManifest = fileNode( name: "upgrade-agent", version: "1.0.0", description: "Canvas plugin", - logo: "assets/preview.png", + extensions: { + "com.github.copilot": { + logo: "assets/preview.png", + }, + }, }), ); @@ -83,19 +87,30 @@ function baseContents(extra) { }; } -// Wires up the directory-walk that resolves plugins/upgrade-agent/extensions to a tree SHA. -// `extensionsEntry` controls what the walk finds at the final ".../extensions" step, and -// `extensionsSubtree` is the recursive listing returned for that resolved tree SHA. -function buildTrees({ extensionsEntry, extensionsSubtree, overrides } = {}) { +function manifestWithExtensions(extensions) { + return fileNode( + JSON.stringify({ + name: "upgrade-agent", + version: "1.0.0", + description: "Canvas plugin", + extensions, + }), + ); +} + +// Wires up the directory-walk that resolves plugins/upgrade-agent/com.github.copilot to a tree +// SHA. `namespaceEntry` controls what the walk finds at the final ".../com.github.copilot" step, +// and `namespaceSubtree` is the recursive listing returned for that resolved tree SHA. +function buildTrees({ namespaceEntry, namespaceSubtree, overrides } = {}) { const trees = { [SHA]: treeResponse([treeEntry("plugins", "tree", TREE_PLUGINS)]), [TREE_PLUGINS]: treeResponse([treeEntry("upgrade-agent", "tree", TREE_UPGRADE_AGENT)]), [TREE_UPGRADE_AGENT]: treeResponse([ - extensionsEntry ?? treeEntry("extensions", "tree", TREE_EXTENSIONS), + namespaceEntry ?? treeEntry("com.github.copilot", "tree", TREE_EXTENSIONS), ]), }; - if (extensionsSubtree) { - trees[TREE_EXTENSIONS] = extensionsSubtree; + if (namespaceSubtree) { + trees[TREE_EXTENSIONS] = namespaceSubtree; } return { ...trees, ...overrides }; } @@ -120,9 +135,10 @@ async function runValidation({ contents, trees }) { test("validateCanvasPluginMetadata accepts a nested extension entry point", async () => { const { errors, warnings } = await runValidation({ trees: buildTrees({ - extensionsSubtree: treeResponse([ - treeEntry("modernize-dashboard", "tree"), - treeEntry("modernize-dashboard/extension.mjs", "blob"), + namespaceSubtree: treeResponse([ + treeEntry("extensions", "tree"), + treeEntry("extensions/modernize-dashboard", "tree"), + treeEntry("extensions/modernize-dashboard/extension.mjs", "blob"), ]), }), }); @@ -131,21 +147,91 @@ test("validateCanvasPluginMetadata accepts a nested extension entry point", asyn assert.deepEqual(warnings, []); }); -test("validateCanvasPluginMetadata accepts a flat extension entry point", async () => { - const { errors, warnings } = await runValidation({ +test("validateCanvasPluginMetadata rejects a manifest missing the com.github.copilot namespace", async () => { + const { errors } = await runValidation({ + contents: { + [`${PLUGIN_ROOT}/.github/plugin/plugin.json`]: { + status: 200, + data: manifestWithExtensions({}), + }, + }, trees: buildTrees({ - extensionsSubtree: treeResponse([treeEntry("extension.mjs", "blob")]), + namespaceSubtree: treeResponse([ + treeEntry("extensions", "tree"), + treeEntry("extensions/modernize-dashboard", "tree"), + treeEntry("extensions/modernize-dashboard/extension.mjs", "blob"), + ]), }), }); - assert.deepEqual(errors, []); + assert.equal( + errors.some((message) => + /must set "extensions\.com\.github\.copilot\.logo" to "assets\/preview\.png"/.test(message), + ), + true, + ); +}); + +test("validateCanvasPluginMetadata rejects a manifest with the wrong logo path", async () => { + const { errors } = await runValidation({ + contents: { + [`${PLUGIN_ROOT}/.github/plugin/plugin.json`]: { + status: 200, + data: manifestWithExtensions({ "com.github.copilot": { logo: "logo.png" } }), + }, + }, + trees: buildTrees({ + namespaceSubtree: treeResponse([ + treeEntry("extensions", "tree"), + treeEntry("extensions/modernize-dashboard", "tree"), + treeEntry("extensions/modernize-dashboard/extension.mjs", "blob"), + ]), + }), + }); + + assert.equal( + errors.some((message) => + /must set "logo" to "assets\/preview\.png" in "extensions\.com\.github\.copilot"/.test(message), + ), + true, + ); +}); + +test("validateCanvasPluginMetadata rejects an extension entry point outside the extensions directory", async () => { + const { errors, warnings } = await runValidation({ + trees: buildTrees({ + namespaceSubtree: treeResponse([ + treeEntry("radius", "tree"), + treeEntry("radius/extension.mjs", "blob"), + ]), + }), + }); + + assert.equal( + errors.some((message) => /must include a canvas extension entry point/.test(message)), + true, + ); assert.deepEqual(warnings, []); }); -test("validateCanvasPluginMetadata rejects when no extension.mjs exists flat or nested", async () => { +test("validateCanvasPluginMetadata rejects a flat extension entry point", async () => { + const { errors, warnings } = await runValidation({ + trees: buildTrees({ + namespaceSubtree: treeResponse([treeEntry("extension.mjs", "blob")]), + }), + }); + + assert.equal( + errors.some((message) => /must include a canvas extension entry point/.test(message)), + true, + ); + assert.deepEqual(warnings, []); +}); + +test("validateCanvasPluginMetadata rejects when no named extension entry point exists", async () => { const { errors } = await runValidation({ trees: buildTrees({ - extensionsSubtree: treeResponse([ + namespaceSubtree: treeResponse([ treeEntry("modernize-dashboard", "tree"), treeEntry("modernize-dashboard/index.mjs", "blob"), ]), @@ -158,44 +244,46 @@ test("validateCanvasPluginMetadata rejects when no extension.mjs exists flat or ); }); -test("validateCanvasPluginMetadata rejects when the extensions directory is missing", async () => { +test("validateCanvasPluginMetadata rejects when the com.github.copilot directory is missing", async () => { const { errors } = await runValidation({ trees: buildTrees({ - extensionsEntry: treeEntry("other-dir", "tree", "tree-other"), + namespaceEntry: treeEntry("other-dir", "tree", "tree-other"), }), }); assert.equal( - errors.some((message) => /must include an "extensions" directory/.test(message)), + errors.some((message) => /must include a "com\.github\.copilot" directory/.test(message)), true, ); }); -test("validateCanvasPluginMetadata rejects when extensions is a file rather than a directory", async () => { +test("validateCanvasPluginMetadata rejects when com.github.copilot is a file rather than a directory", async () => { const { errors } = await runValidation({ trees: buildTrees({ - extensionsEntry: treeEntry("extensions", "blob", "blob-extensions"), + namespaceEntry: treeEntry("com.github.copilot", "blob", "blob-extensions"), }), }); assert.equal( - errors.some((message) => /"extensions" must be a directory/.test(message)), + errors.some((message) => /"com\.github\.copilot" must be a directory/.test(message)), true, ); }); -test("validateCanvasPluginMetadata rejects when extensions/extension.mjs is a directory", async () => { +test("validateCanvasPluginMetadata rejects when a named extension.mjs is a directory", async () => { const { errors } = await runValidation({ trees: buildTrees({ - extensionsSubtree: treeResponse([ - treeEntry("extension.mjs", "tree"), - treeEntry("extension.mjs/placeholder.txt", "blob"), + namespaceSubtree: treeResponse([ + treeEntry("extensions", "tree"), + treeEntry("extensions/modernize-dashboard", "tree"), + treeEntry("extensions/modernize-dashboard/extension.mjs", "tree"), + treeEntry("extensions/modernize-dashboard/extension.mjs/placeholder.txt", "blob"), ]), }), }); assert.equal( - errors.some((message) => /"extensions\/extension\.mjs" must be a file/.test(message)), + errors.some((message) => /"com\.github\.copilot\/extensions\/\/extension\.mjs" must be a file/.test(message)), true, ); }); @@ -219,7 +307,7 @@ test("validateCanvasPluginMetadata treats a truncated walk level as unverifiable trees: buildTrees({ overrides: { [TREE_UPGRADE_AGENT]: treeResponse( - [treeEntry("extensions", "tree", TREE_EXTENSIONS)], + [treeEntry("com.github.copilot", "tree", TREE_EXTENSIONS)], { truncated: true }, ), }, @@ -233,10 +321,10 @@ test("validateCanvasPluginMetadata treats a truncated walk level as unverifiable ); }); -test("validateCanvasPluginMetadata treats a truncated extensions subtree without an entry point as unverifiable", async () => { +test("validateCanvasPluginMetadata treats a truncated com.github.copilot subtree without an entry point as unverifiable", async () => { const { errors, warnings } = await runValidation({ trees: buildTrees({ - extensionsSubtree: treeResponse([treeEntry("modernize-dashboard", "tree")], { truncated: true }), + namespaceSubtree: treeResponse([treeEntry("extensions", "tree")], { truncated: true }), }), }); @@ -250,10 +338,10 @@ test("validateCanvasPluginMetadata treats a truncated extensions subtree without test("validateCanvasPluginMetadata accepts an entry point found within a truncated subtree", async () => { const { errors, warnings } = await runValidation({ trees: buildTrees({ - extensionsSubtree: treeResponse( + namespaceSubtree: treeResponse( [ - treeEntry("modernize-dashboard", "tree"), - treeEntry("modernize-dashboard/extension.mjs", "blob"), + treeEntry("extensions/modernize-dashboard", "tree"), + treeEntry("extensions/modernize-dashboard/extension.mjs", "blob"), ], { truncated: true }, ), diff --git a/eng/external-plugin-quality-gates.mjs b/eng/external-plugin-quality-gates.mjs index fb298412..f0d641a5 100644 --- a/eng/external-plugin-quality-gates.mjs +++ b/eng/external-plugin-quality-gates.mjs @@ -25,6 +25,8 @@ const AGENT_PLUGIN_ALLOWED_TOP_LEVEL_FIELDS = new Set([ ]); const AGENT_PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; const EXTERNAL_CANVAS_KEYWORD = "canvas"; +const COPILOT_CLIENT_NAMESPACE = "com.github.copilot"; +const COPILOT_EXTENSIONS_DIRECTORY = "extensions"; const INFRA_ERROR_PATTERNS = [ /\b401\b/, @@ -804,33 +806,24 @@ function locateCanvasEntryPoint(repoDir, readRef, locator, extensionsDir) { return { entryPoint: null, output: listing.output }; } - let flatIsBlob = false; - let flatIsTree = false; let nestedEntryPoint = null; + let nestedEntryIsNotFile = false; for (const entry of listing.entries) { - if (entry.name === "extension.mjs") { - if (entry.type === "blob") { - flatIsBlob = true; - } else if (entry.type === "tree") { - flatIsTree = true; - } - continue; - } - const segments = entry.name.split("/"); - if (segments.length === 2 && segments[1] === "extension.mjs" && entry.type === "blob" && !nestedEntryPoint) { - nestedEntryPoint = toPosixPath(extensionsDir, segments[0], "extension.mjs"); + if (segments.length === 2 && segments[1] === "extension.mjs" && !nestedEntryPoint) { + if (entry.type === "blob") { + nestedEntryPoint = toPosixPath(extensionsDir, segments[0], "extension.mjs"); + } else { + nestedEntryIsNotFile = true; + } } } - if (flatIsBlob) { - return { entryPoint: toPosixPath(extensionsDir, "extension.mjs"), output: "" }; - } if (nestedEntryPoint) { return { entryPoint: nestedEntryPoint, output: "" }; } - return { entryPoint: null, output: "", flatKindMismatch: flatIsTree }; + return { entryPoint: null, output: "", kindMismatch: nestedEntryIsNotFile }; } export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) { @@ -854,8 +847,8 @@ export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) { }; } - const extensionsDir = toPosixPath(normalizedPluginPath, "extensions"); - const extensionEntryPoint = toPosixPath(extensionsDir, "extension.mjs"); + const namespaceDir = toPosixPath(normalizedPluginPath, COPILOT_CLIENT_NAMESPACE); + const extensionsDir = toPosixPath(namespaceDir, COPILOT_EXTENSIONS_DIRECTORY); let hasFailure = false; let hasInfraError = false; @@ -901,11 +894,13 @@ export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) { } if (!extensionEntryCheck.entryPoint) { hasFailure = true; - if (extensionEntryCheck.flatKindMismatch) { - messages.push(`- ${locator}: "${extensionEntryPoint}" must be a file.`); + if (extensionEntryCheck.kindMismatch) { + messages.push( + `- ${locator}: "${extensionsDir}//extension.mjs" must be a file.`, + ); } else { messages.push( - `- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}" (or a nested "${extensionsDir}//extension.mjs").`, + `- ${locator}: missing required canvas extension entry point "${extensionsDir}//extension.mjs".`, ); } continue; diff --git a/eng/external-plugin-quality-gates.test.mjs b/eng/external-plugin-quality-gates.test.mjs index b24d3037..cad43a88 100644 --- a/eng/external-plugin-quality-gates.test.mjs +++ b/eng/external-plugin-quality-gates.test.mjs @@ -38,10 +38,13 @@ function commitAll(repoDir, message) { return runGit(repoDir, "rev-parse", "HEAD"); } -test("runCanvasStructureGate passes when extensions/extension.mjs exists", () => { +test("runCanvasStructureGate passes when a named extension exists", () => { const repoDir = createTempRepo(); - fs.mkdirSync(path.join(repoDir, "extensions"), { recursive: true }); - fs.writeFileSync(path.join(repoDir, "extensions", "extension.mjs"), "export default {};\n"); + fs.mkdirSync(path.join(repoDir, "com.github.copilot", "extensions", "canvas-plugin"), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, "com.github.copilot", "extensions", "canvas-plugin", "extension.mjs"), + "export default {};\n", + ); const sha = commitAll(repoDir, "Add canvas extension container"); const plugin = { @@ -56,7 +59,7 @@ test("runCanvasStructureGate passes when extensions/extension.mjs exists", () => const result = runCanvasStructureGate(repoDir, plugin, sha); assert.equal(result.status, "pass"); - assert.match(result.output, /found "extensions"/); + assert.match(result.output, /found "com\.github\.copilot\/extensions"/); }); test("runCanvasStructureGate fails when extension entrypoint is only at repo root", () => { @@ -76,13 +79,16 @@ test("runCanvasStructureGate fails when extension entrypoint is only at repo roo const result = runCanvasStructureGate(repoDir, plugin, sha); assert.equal(result.status, "fail"); - assert.match(result.output, /missing required canvas extension directory "extensions"/); + assert.match(result.output, /missing required canvas extension directory "com\.github\.copilot\/extensions"/); }); -test("runCanvasStructureGate fails when extension entrypoint path is a directory", () => { +test("runCanvasStructureGate fails when the named extension entrypoint path is a directory", () => { const repoDir = createTempRepo(); - fs.mkdirSync(path.join(repoDir, "extensions", "extension.mjs"), { recursive: true }); - fs.writeFileSync(path.join(repoDir, "extensions", "extension.mjs", "placeholder.txt"), "not-a-module\n"); + fs.mkdirSync(path.join(repoDir, "com.github.copilot", "extensions", "canvas-plugin", "extension.mjs"), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, "com.github.copilot", "extensions", "canvas-plugin", "extension.mjs", "placeholder.txt"), + "not-a-module\n", + ); const sha = commitAll(repoDir, "Add invalid extension entrypoint directory"); const plugin = { @@ -97,17 +103,14 @@ test("runCanvasStructureGate fails when extension entrypoint path is a directory const result = runCanvasStructureGate(repoDir, plugin, sha); assert.equal(result.status, "fail"); - assert.match(result.output, /"extensions\/extension\.mjs" must be a file/); + assert.match(result.output, /"com\.github\.copilot\/extensions\/\/extension\.mjs" must be a file/); }); -test("runCanvasStructureGate passes when extension lives in a nested subfolder", () => { +test("runCanvasStructureGate fails when the Copilot namespace is missing", () => { const repoDir = createTempRepo(); fs.mkdirSync(path.join(repoDir, "extensions", "modernize-dashboard"), { recursive: true }); - fs.writeFileSync( - path.join(repoDir, "extensions", "modernize-dashboard", "extension.mjs"), - "export default {};\n", - ); - const sha = commitAll(repoDir, "Add nested canvas extension"); + fs.writeFileSync(path.join(repoDir, "extensions", "modernize-dashboard", "extension.mjs"), "export default {};\n"); + const sha = commitAll(repoDir, "Add extension outside Copilot namespace"); const plugin = { name: "canvas-plugin", @@ -120,15 +123,15 @@ test("runCanvasStructureGate passes when extension lives in a nested subfolder", }; const result = runCanvasStructureGate(repoDir, plugin, sha); - assert.equal(result.status, "pass"); - assert.match(result.output, /entry point "extensions\/modernize-dashboard\/extension\.mjs"/); + assert.equal(result.status, "fail"); + assert.match(result.output, /missing required canvas extension directory "com\.github\.copilot\/extensions"/); }); -test("runCanvasStructureGate fails when no extension.mjs exists flat or nested", () => { +test("runCanvasStructureGate fails when no extension.mjs exists in a named directory", () => { const repoDir = createTempRepo(); - fs.mkdirSync(path.join(repoDir, "extensions", "modernize-dashboard"), { recursive: true }); + fs.mkdirSync(path.join(repoDir, "com.github.copilot", "extensions", "modernize-dashboard"), { recursive: true }); fs.writeFileSync( - path.join(repoDir, "extensions", "modernize-dashboard", "index.mjs"), + path.join(repoDir, "com.github.copilot", "extensions", "modernize-dashboard", "index.mjs"), "export default {};\n", ); const sha = commitAll(repoDir, "Add extensions directory without entry point"); @@ -156,15 +159,16 @@ test("runCanvasStructureGate finds a nested extension listed past the legacy out for (let index = 0; index < 160; index += 1) { const filler = path.join( repoDir, + "com.github.copilot", "extensions", `filler-directory-that-pads-the-tree-listing-${String(index).padStart(4, "0")}`, ); fs.mkdirSync(filler, { recursive: true }); fs.writeFileSync(path.join(filler, "readme.txt"), "filler\n"); } - fs.mkdirSync(path.join(repoDir, "extensions", "zzz-real-extension"), { recursive: true }); + fs.mkdirSync(path.join(repoDir, "com.github.copilot", "extensions", "zzz-real-extension"), { recursive: true }); fs.writeFileSync( - path.join(repoDir, "extensions", "zzz-real-extension", "extension.mjs"), + path.join(repoDir, "com.github.copilot", "extensions", "zzz-real-extension", "extension.mjs"), "export default {};\n", ); const sha = commitAll(repoDir, "Add nested extension after many siblings"); @@ -181,7 +185,7 @@ test("runCanvasStructureGate finds a nested extension listed past the legacy out const result = runCanvasStructureGate(repoDir, plugin, sha); assert.equal(result.status, "pass"); - assert.match(result.output, /entry point "extensions\/zzz-real-extension\/extension\.mjs"/); + assert.match(result.output, /entry point "com\.github\.copilot\/extensions\/zzz-real-extension\/extension\.mjs"/); }); // Regression tests for issue #2397: a tag-name locator (e.g. "v1.0.0") must be @@ -207,8 +211,11 @@ function writeValidPluginContent(repoDir) { path.join(repoDir, ".github", "plugin", "plugin.json"), `${JSON.stringify({ name: "tag-plugin", version: "1.0.0" }, null, 2)}\n`, ); - fs.mkdirSync(path.join(repoDir, "extensions"), { recursive: true }); - fs.writeFileSync(path.join(repoDir, "extensions", "extension.mjs"), "export default {};\n"); + fs.mkdirSync(path.join(repoDir, "com.github.copilot", "extensions", "tag-plugin"), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, "com.github.copilot", "extensions", "tag-plugin", "extension.mjs"), + "export default {};\n", + ); } // Mirrors cloneSubmissionRepository in external-plugin-quality-gates.mjs: fetch only the @@ -259,8 +266,8 @@ test("runCanvasStructureGate passes for a tag ref alongside a sha", () => { const result = runCanvasStructureGate(repoDir, plugin, sha); assert.equal(result.status, "pass", result.output); - assert.match(result.output, /- v1\.0\.0: found "extensions"/); - assert.match(result.output, new RegExp(`- ${sha}: found "extensions"`)); + assert.match(result.output, /- v1\.0\.0: found "com\.github\.copilot\/extensions"/); + assert.match(result.output, new RegExp(`- ${sha}: found "com\\.github\\.copilot/extensions"`)); }); test("runVersionMatchGate passes when the primary locator is a tag ref", () => { @@ -296,7 +303,7 @@ test("runCanvasStructureGate passes when the primary locator is a tag ref", () = const result = runCanvasStructureGate(repoDir, plugin, "v1.0.0"); assert.equal(result.status, "pass", result.output); - assert.match(result.output, /- v1\.0\.0: found "extensions"/); + assert.match(result.output, /- v1\.0\.0: found "com\.github\.copilot\/extensions"/); }); test("runRefShaConsistencyGate fails when ref and sha point to different commits", () => {