mirror of
https://github.com/github/awesome-copilot.git
synced 2026-09-03 14:33:40 +00:00
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:
co-authored by
Copilot App
parent
17cd9ee68d
commit
2ba72cd142
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user