Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/wizard-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Wizard E2E

on:
pull_request:
branches: [main]

permissions:
contents: read
pull-requests: read

jobs:
changes:
name: Detect Wizard changes
runs-on: ubuntu-latest
outputs:
wizard: ${{ steps.detect.outputs.wizard }}
steps:
- name: Detect changed Wizard files
id: detect
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
files=$(gh api "repos/$GH_REPO/pulls/$PR_NUMBER/files" --paginate --jq '.[] | .filename, (.previous_filename // empty)')
if grep -q '^plugins/spec-kit-copilot-wizard/' <<< "$files"; then
echo "wizard=true" >> "$GITHUB_OUTPUT"
else
echo "wizard=false" >> "$GITHUB_OUTPUT"
fi

browser:
name: Wizard browser tests
needs: changes
if: needs.changes.outputs.wizard == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package-lock.json
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: wizard-e2e-results
path: plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test-results/
retention-days: 7

gate:
name: Wizard E2E gate
needs: [changes, browser]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check browser result for Wizard changes
env:
DETECTION: ${{ needs.changes.result }}
WIZARD: ${{ needs.changes.outputs.wizard }}
BROWSER: ${{ needs.browser.result }}
run: |
if [[ "$DETECTION" != "success" ]]; then
echo "::error::Could not determine whether Wizard files changed"
exit 1
fi
if [[ "$WIZARD" == "true" && "$BROWSER" != "success" ]]; then
echo "::error::Wizard browser tests did not pass ($BROWSER)"
exit 1
fi
echo "Wizard E2E check complete (changed: $WIZARD)"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.DS_Store
node_modules/
.playwright-mcp/
plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test-results/

# Session artifacts (canvas runtime state, spec-kit init output, css coverage snapshots)
.speckit-wizard/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,40 @@ artifacts, provide input, and run the matching `speckit-*` skill.

![Phases page](../../../../docs/images/wizard-phases.png)

### Canvas designer setup (first increment)

The **Generate canvas** button in the Phases pipeline header opens the
**Canvas designer setup** dialog. It offers separate Presets, Extensions,
and Bundles tabs with source badges and checkboxes for entries tagged
`canvas-design`. Presets and extensions come from the Copilot and Community
catalogs; bundles come from the available Default and Community catalogs.
No eligible items are expected in this first increment, so the tabs show
empty states until those catalogs publish design-time customizations.
Selections reset when the dialog closes and do not install anything in the
current project.
Community selections use the same prominent confirmation warning as
adding an item from the Catalogs page.
Selecting a bundle inspects its components without installing it and checks
same-source presets and extensions already listed in the standalone catalogs.
Those checkboxes remain editable and show which bundle includes them;
components absent from the standalone catalogs are not listed. Removing a
bundle releases its selections unless another selected bundle or a direct
selection still requires them; an inspection failure is shown in the dialog.

**Launch designer** is deliberately disabled. The dialog does not yet
create a nested session or dispatch a launch request; this will be wired
up separately. The Catalogs page continues to install items as before.

### Browser tests

From this extension directory, run `npm ci`, `npx playwright install chromium`,
then `npm run test:e2e`. The tests start a local Wizard server with fixed
catalog data; no `specify` installation or live catalog is required.
`.github/workflows/wizard-e2e.yml` runs them on PRs targeting `main` only
when the Wizard plugin changes. The check is advisory until branch protection
is configured separately; its always-present gate can later be made required
without blocking unrelated PRs on a skipped workflow.

## Quickstart

> This is a **canvas extension** — it opens in the **GitHub Copilot app**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,36 @@

import { hydrateFromCatalogSources, specifyRun } from "./shared.mjs";

export async function inspectBundleMembers(id, workspacePath) {
const stdout = await specifyRun(["bundle", "info", id, "--json"], workspacePath);
if (!stdout?.trim()) throw new Error(`Cannot inspect bundle ${id}; check that it is available to the Specify CLI.`);
let info;
try {
info = JSON.parse(stdout);
} catch {
throw new Error(`Specify returned invalid bundle metadata for ${id}.`);
}
return bundleSelectionMembers(info, id);
}

export function bundleSelectionMembers(info, id) {
if (info?.id !== id || !Array.isArray(info.components)) {
throw new Error(`Specify returned incomplete bundle metadata for ${id}.`);
}
const members = info.components.filter((component) =>
["presets", "extensions"].includes(component.kind)).map((component) => ({
kind: component.kind,
id: component.id,
}));
if (info.components.some((component) =>
!["presets", "extensions", "steps", "workflows"].includes(component.kind))
|| members.some(({ id: memberId }) =>
typeof memberId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(memberId))) {
Comment thread
nicolehaugen marked this conversation as resolved.
throw new Error(`Specify returned unsupported bundle members for ${id}.`);
}
return { source: info.source, members };
}

// Query `specify bundle list` for installed bundles. Tolerant of the
// subcommand being absent — returns empty sets on any error so bundle
// rendering degrades gracefully.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export async function hydrateFromCatalogSources(inst, sources, cfg) {
source: src.name,
version: raw?.version ?? null,
description: raw?.description ?? "",
tags: Array.isArray(raw?.tags) ? raw.tags.filter((tag) => typeof tag === "string") : [],
active: !!installedId,
downloadUrl: raw?.download_url ?? null,
installAllowed: src.installAllowed !== false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const PRESET_CATALOG_URL = {
// <download_url>` and no CLI catalog registration is used.
export const EXTENSION_CATALOG_URL = {
default: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
copilot: "https://raw.githubusercontent.com/github/spec-kit-copilot/main/spec-kit-extensions/catalog.json",
community: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { test, expect } from "@playwright/test";

test.beforeEach(async ({ page }) => {
await page.goto("/?token=e2e-token");
await page.getByRole("tab", { name: "Phases" }).click();
await page.getByRole("button", { name: "Generate canvas" }).click();
});

test("opens a design-only dialog without enabling launch", async ({ page }) => {
const dialog = page.getByRole("dialog", { name: "Canvas designer setup" });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("checkbox", { name: /Design preset/ })).toBeVisible();
await expect(dialog.getByText("Other preset")).toHaveCount(0);
await expect(dialog.getByRole("button", { name: /Launch designer/ })).toBeDisabled();
const presetsTab = dialog.getByRole("tab", { name: "Presets" });
const extensionsTab = dialog.getByRole("tab", { name: "Extensions" });
const bundlesTab = dialog.getByRole("tab", { name: "Bundles" });
await expect(presetsTab).toHaveAttribute("tabindex", "0");
await expect(extensionsTab).toHaveAttribute("tabindex", "-1");
await page.keyboard.press("Tab");
await expect(presetsTab).toBeFocused();
await page.keyboard.press("ArrowRight");
await expect(extensionsTab).toBeFocused();
await expect(dialog.getByRole("tabpanel", { name: "Extensions" })).toBeVisible();
await page.keyboard.press("End");
await expect(bundlesTab).toBeFocused();
await page.keyboard.press("ArrowRight");
await expect(presetsTab).toBeFocused();
await page.keyboard.press("ArrowLeft");
await expect(bundlesTab).toBeFocused();
await page.keyboard.press("Home");
await expect(presetsTab).toBeFocused();
await bundlesTab.click();
await expect(bundlesTab).toHaveAttribute("tabindex", "0");
await expect(presetsTab).toHaveAttribute("tabindex", "-1");
await expect(dialog.getByRole("checkbox", { name: /Design bundle/ })).toBeVisible();
await dialog.getByRole("checkbox", { name: /Default bundle/ }).check();
await expect(dialog.getByRole("checkbox", { name: /Default bundle/ })).toBeChecked();
await expect(dialog.getByText("Other bundle")).toHaveCount(0);
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).toHaveCount(0);
await page.getByRole("button", { name: "Generate canvas" }).click();
await expect(page.getByRole("dialog", { name: "Canvas designer setup" })
.getByRole("checkbox", { name: /Design preset/ })).not.toBeChecked();
});

test("confirms community selection and checks only listed design bundle members", async ({ page }) => {
const writes = [];
page.on("request", (request) => {
if (request.method() !== "GET") writes.push(request.url());
});
const dialog = page.getByRole("dialog", { name: "Canvas designer setup" });
await dialog.getByRole("tab", { name: "Bundles" }).click();
const community = dialog.getByRole("checkbox", { name: /Community bundle/ });
await community.check();
const warning = page.getByRole("dialog", { name: "Select community bundle?" });
await expect(warning.getByText(/not reviewed, audited, or endorsed/)).toBeVisible();
await expect(page.locator(".designer-backdrop")).toHaveJSProperty("inert", true);
await expect(page.locator(".designer-backdrop")).toHaveAttribute("aria-hidden", "true");
await expect(dialog).toHaveCount(0);
await warning.getByRole("button", { name: "Cancel" }).click();
await expect(page.locator(".designer-backdrop")).toHaveJSProperty("inert", false);
await expect(page.locator(".designer-backdrop")).not.toHaveAttribute("aria-hidden", "true");
await expect(dialog).toBeVisible();
await expect(community).toBeFocused();
await expect(community).not.toBeChecked();
await community.check();
await warning.getByRole("button", { name: "Select anyway" }).click();
await expect(page.locator(".designer-backdrop")).toHaveJSProperty("inert", false);
await expect(page.locator(".designer-backdrop")).not.toHaveAttribute("aria-hidden", "true");
await expect(community).toBeFocused();
await expect(community).toBeChecked();

await dialog.getByRole("checkbox", { name: /Design bundle/ }).check();
await warning.getByRole("button", { name: "Select anyway" }).click();
await dialog.getByRole("tab", { name: "Presets" }).click();
const presets = dialog.getByRole("tabpanel", { name: "Presets" });
const preset = presets.getByRole("checkbox", { name: /Design preset/ });
await expect(preset).toBeChecked();
await expect(presets.getByText("Included by bundle: Design bundle")).toBeVisible();
await expect(presets.getByText("Unlisted preset")).toHaveCount(0);
await expect(presets.getByRole("checkbox", { name: /Copilot preset/ })).not.toBeChecked();
await preset.uncheck();
await expect(preset).not.toBeChecked();
await dialog.getByRole("tab", { name: "Extensions" }).click();
await expect(dialog.getByRole("checkbox", { name: /Design extension/ })).toBeChecked();
await expect(dialog.getByText("Unlisted extension")).toHaveCount(0);
await expect(dialog.getByRole("button", { name: /Launch designer/ })).toBeDisabled();
expect(writes).toEqual([]);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { createServer } from "node:http";
import { createHandler } from "../server.mjs";

const snapshot = {
workspacePath: process.cwd(),
currentPhase: "constitution",
setup: {
pluginInstalled: true,
cliInstalled: true,
projectInitialized: true,
skillsReloaded: true,
},
boot: { phase: "ready", steps: [] },
phases: {},
commands: [],
catalog: {
presets: [
{ id: "design-preset", name: "Design preset", source: "community", tags: ["canvas-design"] },
{ id: "foreign-preset", name: "Copilot preset", source: "copilot", tags: ["canvas-design"] },
{ id: "other-preset", name: "Other preset", source: "copilot", tags: ["other"] },
{ id: "unlisted-preset", name: "Unlisted preset", source: "copilot" },
],
extensions: [
{ id: "design-extension", name: "Design extension", source: "community", tags: ["canvas-design"] },
{ id: "unlisted-extension", name: "Unlisted extension", source: "copilot", tags: ["other"] },
],
bundles: [
{ id: "design-bundle", name: "Design bundle", source: "community", tags: ["canvas-design"] },
{ id: "default-bundle", name: "Default bundle", source: "default", tags: ["canvas-design"] },
{ id: "community-bundle", name: "Community bundle", source: "community", tags: ["canvas-design"] },
{ id: "other-bundle", name: "Other bundle", source: "default", tags: ["design"] },
],
},
};

const members = {
"design-bundle": [
{ kind: "presets", id: "design-preset" },
{ kind: "presets", id: "foreign-preset" },
{ kind: "presets", id: "unlisted-preset" },
{ kind: "extensions", id: "design-extension" },
],
"default-bundle": [],
"community-bundle": [],
};

const handler = createHandler({
token: "e2e-token",
session: { send: async () => { throw new Error("E2E fixture must not dispatch a session"); } },
log: async (message) => { console.error(message); },
getState: async () => snapshot,
getInstance: () => ({ workspacePath: process.cwd() }),
broadcast: () => {},
registerSse: (_req, res) => { res.on("close", () => {}); },
inspectBundle: async (id) => ({
source: id === "default-bundle" ? "default" : "community",
members: members[id] ?? [],
}),
});

createServer((req, res) => { void handler(req, res); }).listen(4177, "127.0.0.1");
Original file line number Diff line number Diff line change
Expand Up @@ -282,13 +282,21 @@ async function hydrateCatalogs(inst) {
builtin: true,
priority: 1,
},
{
name: "copilot",
url: EXTENSION_CATALOG_URL.copilot,
description: "Copilot-specific Spec Kit extensions",
installAllowed: true,
builtin: true,
priority: 2,
},
{
name: "community",
url: EXTENSION_CATALOG_URL.community,
description: "Community-contributed extensions",
installAllowed: false,
builtin: true,
priority: 2,
priority: 3,
},
];
inst.cachedExtensionCatalogSources = extBootstrap;
Expand Down
Loading
Loading