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/<name> 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-name>/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>/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
This commit is contained in:
Aaron Powell
2026-09-03 19:14:23 +10:00
committed by GitHub
co-authored by Copilot App
parent 17cd9ee68d
commit 2ba72cd142
4 changed files with 211 additions and 131 deletions
+37 -47
View File
@@ -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 "<name>/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-name>/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>/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>/extension.mjs" at ${releaseLocatorDescription}`,
`submission: plugins tagged with "canvas" must include a canvas extension entry point at "${COPILOT_CLIENT_NAMESPACE}/${COPILOT_EXTENSIONS_DIRECTORY}/<extension>/extension.mjs" at ${releaseLocatorDescription}`,
);
}
}
+122 -34
View File
@@ -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>\/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 },
),
+17 -22
View File
@@ -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>/extension.mjs" must be a file.`,
);
} else {
messages.push(
`- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}" (or a nested "${extensionsDir}/<extension>/extension.mjs").`,
`- ${locator}: missing required canvas extension entry point "${extensionsDir}/<extension>/extension.mjs".`,
);
}
continue;
+35 -28
View File
@@ -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>\/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", () => {