From 23ecb7644db5deb4b15a1b0650e140aefe3b0858 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Wed, 24 Jun 2026 22:40:27 +0500 Subject: [PATCH 1/2] fix(website): reveal docs sidebar on phones + Playwright nav smoke tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs section submenu was unreachable below 768px: mobile-menu.js already toggles `.open` on `.sidebar`, but styles.css had no `.sidebar.open` reveal rule (only `.nav-links.open`), so the JS toggle had no visual effect and the per-section nav was a dead end on mobile. Fix [WEBSITE-MOBILE-DOCS-NAV]: add `.sidebar.open { display: block; }` under the 768px breakpoint, mirroring the existing top-nav pattern. Add Playwright navigation smoke tests [WEBSITE-E2E-SMOKE] that guard this and basic nav on desktop (Desktop Chrome) + phone (Pixel 5) viewports against the production `_site/` build, served by a dependency-free static server. Wired into the website CI job with the `list` stdout reporter only — no HTML report, trace, video or screenshot is produced or uploaded ([GITHUB-NO-ARTIFACTS]). Fixes #186 Closes #187 --- .github/workflows/ci.yml | 12 ++++ docs/INDEX.md | 1 + docs/specs/WEBSITE-E2E-SPEC.md | 60 +++++++++++++++++++ website/.gitignore | 5 ++ website/package-lock.json | 82 +++++++++++++++++++++++++ website/package.json | 6 +- website/playwright.config.ts | 36 +++++++++++ website/src/assets/css/styles.css | 4 ++ website/tests/e2e/navigation.spec.ts | 90 ++++++++++++++++++++++++++++ website/tests/static-server.js | 70 ++++++++++++++++++++++ 10 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 docs/specs/WEBSITE-E2E-SPEC.md create mode 100644 website/playwright.config.ts create mode 100644 website/tests/e2e/navigation.spec.ts create mode 100644 website/tests/static-server.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43ca52b1..60f30066 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,18 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: npm run build + # Navigation smoke tests ([WEBSITE-E2E-SMOKE]). Both presets (Desktop + # Chrome + Pixel 5) run on Chromium, so only chromium is installed. + - name: Install Playwright browser + working-directory: website + run: npx playwright install --with-deps chromium + + - name: Run navigation smoke tests (desktop + mobile) + working-directory: website + # CI uses the stdout `list` reporter only — no HTML report, trace, + # video or screenshot is produced or uploaded ([GITHUB-NO-ARTIFACTS]). + run: npm run test:e2e + # ── Lint (runs in parallel with all test jobs) ───────────────────────────── lint: name: Lint diff --git a/docs/INDEX.md b/docs/INDEX.md index 1c984059..4b60a45e 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -24,6 +24,7 @@ Specifications define the target behavior and architecture. They are the source | [ZED-SPEC.md](specs/ZED-SPEC.md) | Zed extension (WASM) — LSP integration, tree-sitter grammars, DAP debugging. | | [LSP-TEST-INTEGRATION-SPEC.md](specs/LSP-TEST-INTEGRATION-SPEC.md) | Test discovery, execution, and editor integration — pytest/unittest, TestItem model, coverage overlay. | | [EXTENSION-ACTIVITY-PANEL-SPEC.md](specs/EXTENSION-ACTIVITY-PANEL-SPEC.md) | Cross-editor activity panel — module explorer, type health, feature dashboard (VS Code, Zed, Neovim). | +| [WEBSITE-E2E-SPEC.md](specs/WEBSITE-E2E-SPEC.md) | Website navigation/e2e smoke tests (Playwright, desktop + mobile) — top-nav resolution, docs sidebar, and the mobile docs-submenu reachability guard. | ## Plans diff --git a/docs/specs/WEBSITE-E2E-SPEC.md b/docs/specs/WEBSITE-E2E-SPEC.md new file mode 100644 index 00000000..3b49d375 --- /dev/null +++ b/docs/specs/WEBSITE-E2E-SPEC.md @@ -0,0 +1,60 @@ +# Website: Navigation & End-to-End Smoke Tests {#WEBSITE-E2E} + +**Version**: 0.1.0 +**Status**: Active +**License**: MIT + +--- + +## Purpose {#WEBSITE-E2E-PURPOSE} + +The marketing/docs site (`website/`) is a statically generated Eleventy build. +Before this spec, CI built the site but never exercised it, so navigation +regressions shipped silently. This spec defines browser smoke tests that run the +**production build** of `_site/` on both a desktop and a real phone viewport, so +the core "can a visitor actually get around the site" guarantees are enforced in +CI. + +## Smoke Coverage {#WEBSITE-E2E-SMOKE} + +Implemented by `website/tests/e2e/navigation.spec.ts`, driven by +`website/playwright.config.ts` (two projects: `desktop` = Desktop Chrome, +`mobile` = Pixel 5) and served by the dependency-free static server +`website/tests/static-server.js`. Run with `npm run test:e2e` +(`test:e2e:ui` locally). + +The suite asserts, on each relevant viewport: + +- **Top navigation resolves** — the home page links to Docs, Rules, Blog and + GitHub (matched by `href`, so the check holds even where the nav is collapsed + behind the hamburger on a phone). +- **Docs landing page loads** — `/docs/` renders with the docs sidebar present. +- **Desktop sidebar** — the docs sidebar is permanently visible and navigates + between sections without any toggle. +- **Mobile docs submenu** — see [WEBSITE-MOBILE-DOCS-NAV]. +- **Mobile top nav** — the hamburger reveals the collapsed top nav. + +### CI constraint {#WEBSITE-E2E-NO-ARTIFACTS} + +Per `[GITHUB-NO-ARTIFACTS]`, the CI run emits only the stdout `list` reporter. +No Playwright HTML report, trace, video or screenshot is produced or uploaded — +those (HTML report + on-retry trace) are reserved for local runs and are +git-ignored (`website/.gitignore`). The website CI job +(`.github/workflows/ci.yml`) installs only the Chromium browser, since both +presets run on Chromium. + +## Mobile Docs Submenu Reachability {#WEBSITE-MOBILE-DOCS-NAV} + +On phones (`max-width: 768px`) the docs section sidebar collapses so the article +body is readable. It **must** remain reachable: the hamburger toggle +(`mobile-menu.js`, which adds `.open` to `.sidebar`) reveals it via the CSS rule +`.sidebar.open { display: block; }` in `website/src/assets/css/styles.css`, +mirroring the existing `.nav-links.open` rule for the top nav. + +Without that reveal rule the JS toggle has no visual effect and the per-section +submenu (Installation, Quick Start, Configuration, Diagnostics, Reference, …) is +unreachable on a phone — the regression tracked as issue #186. The guard test is +`"docs section submenu is reachable via the hamburger"` in +`website/tests/e2e/navigation.spec.ts`: it asserts the submenu is hidden by +default, becomes visible after the hamburger is tapped, and navigates to the +chosen section. diff --git a/website/.gitignore b/website/.gitignore index c4c436e8..e0708079 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -3,3 +3,8 @@ _site/ .eleventy-cache/ .DS_Store *.local + +# Playwright local outputs — never committed, never a CI artifact ([GITHUB-NO-ARTIFACTS]) +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/website/package-lock.json b/website/package-lock.json index 83c3f4a2..0ff33bb4 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "devDependencies": { "@11ty/eleventy": "^3.1.6", + "@playwright/test": "^1.61.1", + "@types/node": "^26.0.0", "eleventy-plugin-techdoc": "^0.2.0", "markdown-it": "^14.2.0" } @@ -587,6 +589,22 @@ } } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sindresorhus/slugify": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", @@ -648,6 +666,16 @@ "license": "MIT", "peer": true }, + "node_modules/@types/node": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/a-sync-waterfall": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", @@ -1863,6 +1891,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/please-upgrade-node": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", @@ -2184,6 +2259,13 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/website/package.json b/website/package.json index 6facc704..43ec3da4 100644 --- a/website/package.json +++ b/website/package.json @@ -7,10 +7,14 @@ "scripts": { "build": "eleventy", "start": "eleventy --serve --watch", - "clean": "rm -rf _site" + "clean": "rm -rf _site", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "devDependencies": { "@11ty/eleventy": "^3.1.6", + "@playwright/test": "^1.61.1", + "@types/node": "^26.0.0", "eleventy-plugin-techdoc": "^0.2.0", "markdown-it": "^14.2.0" } diff --git a/website/playwright.config.ts b/website/playwright.config.ts new file mode 100644 index 00000000..c0a4e23a --- /dev/null +++ b/website/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Implements [WEBSITE-E2E-SMOKE]: navigation smoke tests on a desktop and a +// real phone viewport, run against the production build of `_site/`. +// See docs/specs/WEBSITE-E2E-SPEC.md. +// +// CI constraint [GITHUB-NO-ARTIFACTS]: no HTML report / trace / video / screenshot +// is ever uploaded. In CI we emit only the stdout `list` reporter; the HTML +// report and on-retry traces are reserved for local runs and stay on disk. +const isCI = !!process.env.CI; +const PORT = Number(process.env.PORT ?? 8099); +const baseURL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 1 : 0, + reporter: isCI ? [["list"]] : [["list"], ["html", { open: "never" }]], + use: { + baseURL, + trace: isCI ? "off" : "on-first-retry", + screenshot: "off", + video: "off", + }, + projects: [ + { name: "desktop", use: { ...devices["Desktop Chrome"] } }, + { name: "mobile", use: { ...devices["Pixel 5"] } }, + ], + webServer: { + command: "node tests/static-server.js", + url: baseURL, + reuseExistingServer: !isCI, + timeout: 30_000, + }, +}); diff --git a/website/src/assets/css/styles.css b/website/src/assets/css/styles.css index 1a4b3491..a0f99750 100644 --- a/website/src/assets/css/styles.css +++ b/website/src/assets/css/styles.css @@ -248,7 +248,11 @@ button { cursor: pointer; font-family: inherit; border: none; background: none; .sidebar { position: static; max-height: none; padding-right: 0; border-bottom: 1px solid var(--color-border); padding-bottom: var(--space-6); } } @media (max-width: 768px) { + /* [WEBSITE-MOBILE-DOCS-NAV] On phones the docs sidebar collapses, but the + hamburger (mobile-menu.js toggles `.open`) must be able to reveal it — else + the per-section submenu is unreachable. Mirrors the `.nav-links.open` rule. */ .sidebar { display: none; } + .sidebar.open { display: block; } } .docs-nav, .sidebar nav { padding: 0; } diff --git a/website/tests/e2e/navigation.spec.ts b/website/tests/e2e/navigation.spec.ts new file mode 100644 index 00000000..87afb170 --- /dev/null +++ b/website/tests/e2e/navigation.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from "@playwright/test"; + +// Implements [WEBSITE-E2E-SMOKE]: navigation smoke tests on desktop + phone. +// See docs/specs/WEBSITE-E2E-SPEC.md. + +// Top-level nav links exist and resolve on every viewport (the markup is in the +// DOM on mobile too — it is just collapsed behind the hamburger). +const TOP_NAV = [ + { name: "Docs", href: "/docs/" }, + { name: "Rules", href: "/docs/rules/" }, + { name: "Blog", href: "/blog/" }, + { name: "GitHub", href: "https://github.com/Nimblesite/Basilisk" }, +]; + +test.describe("top navigation", () => { + // Viewport-agnostic: on a phone the nav is collapsed (display:none), so we + // match the anchors by href rather than by visible role. + test("home page links to Docs, Rules, Blog and GitHub", async ({ page }) => { + await page.goto("/"); + for (const { name, href } of TOP_NAV) { + const link = page.locator(`.nav-links a[href="${href}"]`); + await expect(link).toHaveCount(1); + await expect(link).toHaveText(new RegExp(name)); + } + }); + + test("Docs nav link loads the docs landing page", async ({ page }) => { + await page.goto("/docs/"); + await expect(page.locator("#docs-sidebar")).toBeAttached(); + await expect(page).toHaveTitle(/.+/); + }); +}); + +test.describe("docs sidebar (desktop)", () => { + test.skip( + ({ isMobile }) => !!isMobile, + "desktop layout keeps the sidebar permanently visible", + ); + + test("sidebar is visible with no toggle and navigates between sections", async ({ + page, + }) => { + await page.goto("/docs/installation/"); + const sidebar = page.locator("#docs-sidebar"); + await expect(sidebar).toBeVisible(); + + await sidebar.getByRole("link", { name: "Quick Start" }).click(); + await expect(page).toHaveURL(/\/docs\/quick-start\/$/); + }); +}); + +test.describe("docs sidebar (mobile)", () => { + test.skip( + ({ isMobile }) => !isMobile, + "this guards the phone-only collapsed-sidebar behaviour", + ); + + // Guards issue #186 — [WEBSITE-MOBILE-DOCS-NAV]. On a phone the docs section + // submenu must be reachable: it starts collapsed, and the hamburger reveals it. + test("docs section submenu is reachable via the hamburger", async ({ page }) => { + await page.goto("/docs/installation/"); + + const submenuLink = page + .locator("#docs-sidebar") + .getByRole("link", { name: "Quick Start" }); + + // Collapsed by default so the doc body is readable without a wall of links. + await expect(submenuLink).toBeHidden(); + + // The hamburger must reveal the docs submenu (not just the top nav). + await page.locator("#mobile-menu-toggle").click(); + await expect(submenuLink).toBeVisible(); + + // …and it actually navigates to the chosen section. + await submenuLink.scrollIntoViewIfNeeded(); + await submenuLink.click(); + await expect(page).toHaveURL(/\/docs\/quick-start\/$/); + }); + + test("hamburger also opens the top nav", async ({ page }) => { + await page.goto("/docs/installation/"); + const docsLink = page + .locator(".nav-links") + .getByRole("link", { name: "Blog", exact: true }); + + await expect(docsLink).toBeHidden(); + await page.locator("#mobile-menu-toggle").click(); + await expect(docsLink).toBeVisible(); + }); +}); diff --git a/website/tests/static-server.js b/website/tests/static-server.js new file mode 100644 index 00000000..9b2241d5 --- /dev/null +++ b/website/tests/static-server.js @@ -0,0 +1,70 @@ +// Dependency-free static file server for the built `_site/` output. +// Used by Playwright's `webServer` so e2e tests run against the real, +// production-built site (no Eleventy dev server / live-reload in the loop). +// +// Implements [WEBSITE-E2E-SMOKE]: serve the static build so navigation +// smoke tests can exercise it. See docs/specs/WEBSITE-E2E-SPEC.md. +import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { join, normalize, extname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("../_site/", import.meta.url)); +const PORT = Number(process.env.PORT ?? 8099); + +const MIME = new Map([ + [".html", "text/html; charset=utf-8"], + [".css", "text/css; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".mjs", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".svg", "image/svg+xml"], + [".png", "image/png"], + [".jpg", "image/jpeg"], + [".webp", "image/webp"], + [".xml", "application/xml; charset=utf-8"], + [".txt", "text/plain; charset=utf-8"], + [".woff2", "font/woff2"], +]); + +// Resolve a request path to a file on disk, mapping directory URLs to their +// `index.html` and rejecting any path that escapes the `_site/` root. +async function resolvePath(urlPath) { + const decoded = decodeURIComponent(urlPath.split("?")[0].split("#")[0]); + const rel = normalize(decoded).replace(/^(\.\.[/\\])+/, ""); + let candidate = join(ROOT, rel); + if (!candidate.startsWith(ROOT)) { + return null; + } + if (candidate.endsWith("/") || candidate === ROOT.replace(/[/\\]$/, "")) { + candidate = join(candidate, "index.html"); + } + try { + const info = await stat(candidate); + return info.isDirectory() ? join(candidate, "index.html") : candidate; + } catch { + return candidate.endsWith(".html") ? candidate : `${candidate}/index.html`; + } +} + +const server = createServer(async (req, res) => { + const filePath = await resolvePath(req.url ?? "/"); + if (!filePath) { + res.writeHead(403).end("Forbidden"); + return; + } + try { + const body = await readFile(filePath); + res.writeHead(200, { + "content-type": MIME.get(extname(filePath)) ?? "application/octet-stream", + }); + res.end(body); + } catch { + res.writeHead(404, { "content-type": "text/html; charset=utf-8" }); + res.end("

404

"); + } +}); + +server.listen(PORT, () => { + process.stdout.write(`static-server: serving _site on http://127.0.0.1:${PORT}\n`); +}); From 94faddd1715d2539feda2bb7a533ea17e9e4554a Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Thu, 25 Jun 2026 13:53:19 +0500 Subject: [PATCH 2/2] =?UTF-8?q?feat(vscode):=20explicit=20Module=20Explore?= =?UTF-8?q?r=20sort=20picker=20=E2=80=94=20name,=20path,=20type=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the blind worst/best/alpha sort cycle with an explicit QuickPick of three labelled modes — Module Name, Path, Type Coverage — with the active mode checked so the current sort is always visible. Adds sort-by-path (new) and keeps coverage (ascending, least-typed first) as the default. - module-explorer.ts: SortMode name|path|coverage; setSortMode/getSortMode/ sortOptions(); QuickPick-driven command; drop cycleSortMode/SORT_CYCLE. - package.json: command title "Toggle Sort Order" -> "Sort Modules…". - tests: explicit-modes + sort-by-path coverage; dismiss the picker in the command-executable test. - spec: EXTENSION-ACTIVITY-PANEL-SPEC.md sort sections updated. Closes #189 --- docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md | 12 ++-- vscode-extension/package.json | 2 +- vscode-extension/src/module-explorer.ts | 58 +++++++++++---- .../src/test/suite/activity-panel.test.ts | 14 +++- .../test/suite/module-explorer-tree.test.ts | 70 +++++++++++++++---- 5 files changed, 120 insertions(+), 36 deletions(-) diff --git a/docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md b/docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md index 4becb060..4261f8f5 100644 --- a/docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md +++ b/docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md @@ -250,7 +250,8 @@ name into path segments and threading it into a node trie **Flat view (`flat`, opt-in toggle).** Flat view drops the folder nesting and lists **every module** as one sortable row labelled by its full dotted name, -ordered by the sort toggle (worst/best/alpha). It is "flat" only in that folders +ordered by the selected sort mode (module name / path / type coverage — #189). +It is "flat" only in that folders are not nested — symbols still expand **under their owning module** and are **never** dumped bare at the tree root (the #149 §2 flat-mode defect). The default view is always the nested tree. @@ -292,7 +293,7 @@ default view is always the nested tree. | Collapse All | VS Code's **native** `showCollapseAll` button — never a contributed command. A custom collapse command alongside it is a duplicate (issue #113). | | Filter | Toggle filter input to search modules/symbols by name | | Toggle View | Switch between tree (nested folder/package hierarchy, default) and flat (every module as one sortable row) | -| Sort | Cycle worst-first -> best-first -> alphabetical. Applied only in flat view; tree view stays structural. Its toolbar entry is **gated on `basilisk.moduleExplorerView == 'flat'`** so it is hidden in tree view rather than rendering as a silent no-op (issue #151). Carried over from the merged Type Health panel. | +| Sort | Open an explicit picker of three labelled modes — **Module Name**, **Path**, **Type Coverage** — with the active mode checked, so the current sort is always visible (no blind cycle, issue #189). Coverage sorts ascending (least-typed first), the default. Applied only in flat view; tree view stays structural. Its toolbar entry is **gated on `basilisk.moduleExplorerView == 'flat'`** so it is hidden in tree view rather than rendering as a silent no-op (issue #151). Carried over from the merged Type Health panel. | | Fix All | Run `basilisk.fixWorkspace`. Promoted from the info panel (issue #103); `when`-gated on `basilisk.serverState == 'running'` **and** the `config.basilisk.experimental.fixAll` flag (default off, issue #113). | | Organize Imports | Run `basilisk.organizeImports`. Same promotion + gating. | | Restart Server | Run `basilisk.restartServer`. Same promotion + gating. | @@ -338,8 +339,11 @@ At-a-glance view of how well-typed the codebase is. Answers: "How much of my cod > command, `TypeHealthResponse`, and the tree structure below remain the **shared > health surface** for editors without a unified panel (Zed `/health`, Neovim > `:BasiliskHealth`), computed from the same per-file figures as the folded rollup. -> The icon thresholds, coverage bar, `[adopted]` badge, and worst-first sort -> described here all carry over to the merged panel. +> The icon thresholds, coverage bar, and `[adopted]` badge described here all +> carry over to the merged panel — whose flat-view sort is the explicit +> Module Name / Path / Type Coverage picker +> ([EXTACT-MODULES-TOOLBAR](#EXTACT-MODULES-TOOLBAR), #189), defaulting to +> least-typed-first. ### Tree Structure {#EXTACT-HEALTH-TREE-STRUCTURE} diff --git a/vscode-extension/package.json b/vscode-extension/package.json index e5e989b0..66a7cc9b 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -189,7 +189,7 @@ }, { "command": "basilisk.sortModuleExplorer", - "title": "Toggle Sort Order", + "title": "Sort Modules…", "category": "Basilisk", "icon": "$(sort-precedence)" }, diff --git a/vscode-extension/src/module-explorer.ts b/vscode-extension/src/module-explorer.ts index 995f1ea6..77a106bc 100644 --- a/vscode-extension/src/module-explorer.ts +++ b/vscode-extension/src/module-explorer.ts @@ -316,10 +316,20 @@ export function workspaceHealthBadge(stats: HealthStats | undefined): vscode.Vie /** View mode for module explorer: tree (hierarchical) or flat (all symbols). */ type ViewMode = "tree" | "flat"; -/** Sort mode applied in flat view (tree view stays structural). */ -type SortMode = "worst" | "best" | "alpha"; +/** Sort mode applied in flat view (tree view stays structural) — #189. */ +type SortMode = "name" | "path" | "coverage"; -const SORT_CYCLE: readonly SortMode[] = ["worst", "best", "alpha"]; +/** + * The three explicit, labelled sort options surfaced in the picker (#189), + * replacing the old blind worst/best/alpha cycle. `coverage` is labelled "Type + * Coverage" to match the panel's existing "Coverage"/"% typed" wording (the + * `coveragePercent` field is type-coverage, not the PEP conformance score). + */ +const SORT_OPTIONS: readonly { readonly mode: SortMode; readonly label: string }[] = [ + { mode: "name", label: "Module Name" }, + { mode: "path", label: "Path" }, + { mode: "coverage", label: "Type Coverage" }, +]; export class ModuleExplorerProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly emitter = new vscode.EventEmitter(); @@ -329,7 +339,7 @@ export class ModuleExplorerProvider implements vscode.TreeDataProvider private workspace: HealthStats | undefined; public readonly disposables: vscode.Disposable[] = []; private viewMode: ViewMode = "tree"; - private sortMode: SortMode = "worst"; + private sortMode: SortMode = "coverage"; private filterPattern = ""; private treeView: vscode.TreeView | undefined; @@ -346,13 +356,22 @@ export class ModuleExplorerProvider implements vscode.TreeDataProvider this.emitter.fire(undefined); } - /** Cycle the flat-view sort: worst-first -> best-first -> alphabetical. */ - public cycleSortMode(): void { - const idx = SORT_CYCLE.indexOf(this.sortMode); - this.sortMode = SORT_CYCLE[(idx + 1) % SORT_CYCLE.length]; + /** The active flat-view sort mode (surfaced in the picker, #189). */ + public getSortMode(): SortMode { + return this.sortMode; + } + + /** Select the flat-view sort mode explicitly and re-render (#189). */ + public setSortMode(mode: SortMode): void { + this.sortMode = mode; this.emitter.fire(undefined); } + /** Labelled sort options with the active one marked, to drive the picker (#189). */ + public sortOptions(): readonly { readonly mode: SortMode; readonly label: string; readonly current: boolean }[] { + return SORT_OPTIONS.map((option) => ({ ...option, current: option.mode === this.sortMode })); + } + /** Toggle between tree and flat view modes, persisted in workspaceState. */ public toggleViewMode(context: vscode.ExtensionContext): void { this.viewMode = this.viewMode === "tree" ? "flat" : "tree"; @@ -496,12 +515,13 @@ export class ModuleExplorerProvider implements vscode.TreeDataProvider }); } - /** Order modules for flat view per the current sort toggle. */ + /** Order modules for flat view per the current sort selection (#189). */ private sortModules(modules: ModuleNode[]): ModuleNode[] { switch (this.sortMode) { - case "worst": return modules.sort((a, b) => a.coveragePercent - b.coveragePercent); - case "best": return modules.sort((a, b) => b.coveragePercent - a.coveragePercent); - case "alpha": return modules.sort((a, b) => a.name.localeCompare(b.name)); + case "name": return modules.sort((a, b) => a.name.localeCompare(b.name)); + case "path": return modules.sort((a, b) => a.path.localeCompare(b.path)); + // Ascending coverage surfaces the least-typed modules first. + case "coverage": return modules.sort((a, b) => a.coveragePercent - b.coveragePercent); } } @@ -580,8 +600,18 @@ function registerExplorerCommands( vscode.commands.registerCommand("basilisk.toggleModuleExplorerView", () => { provider.toggleViewMode(context); }), - vscode.commands.registerCommand("basilisk.sortModuleExplorer", () => { - provider.cycleSortMode(); + vscode.commands.registerCommand("basilisk.sortModuleExplorer", async () => { + // Explicit picker with the active mode checked, so the current sort is + // always visible — never a blind cycle (#189). + const items = provider.sortOptions().map((option) => ({ + label: option.current ? `$(check) ${option.label}` : option.label, + mode: option.mode, + })); + const choice = await vscode.window.showQuickPick(items, { + title: "Sort Modules", + placeHolder: "Sort the flat module list by…", + }); + if (choice !== undefined) { provider.setSortMode(choice.mode); } }), vscode.commands.registerCommand("basilisk.filterModuleExplorer", async () => { const input = await vscode.window.showInputBox({ diff --git a/vscode-extension/src/test/suite/activity-panel.test.ts b/vscode-extension/src/test/suite/activity-panel.test.ts index 2fea4417..c5ddaea6 100644 --- a/vscode-extension/src/test/suite/activity-panel.test.ts +++ b/vscode-extension/src/test/suite/activity-panel.test.ts @@ -243,8 +243,18 @@ suite("Basilisk Activity Panel E2E Tests", function () { await vscode.commands.executeCommand("basilisk.toggleModuleExplorerView"); }); - test("sortModuleExplorer command is executable", async function () { - await vscode.commands.executeCommand("basilisk.sortModuleExplorer"); + test("sortModuleExplorer command opens the sort picker (#189)", async function () { + // The command now shows a QuickPick of the explicit sort modes; dismiss it + // so the test exercises the command without blocking on user input. + const dismiss = new Promise((resolve) => { + setTimeout(() => { + void vscode.commands.executeCommand("workbench.action.closeQuickOpen").then(() => { resolve(); }); + }, 200); + }); + await Promise.all([ + vscode.commands.executeCommand("basilisk.sortModuleExplorer"), + dismiss, + ]); }); // ── Info Panel Commands ─────────────────────────────────────────────── diff --git a/vscode-extension/src/test/suite/module-explorer-tree.test.ts b/vscode-extension/src/test/suite/module-explorer-tree.test.ts index 552c5e34..ce7cd6a6 100644 --- a/vscode-extension/src/test/suite/module-explorer-tree.test.ts +++ b/vscode-extension/src/test/suite/module-explorer-tree.test.ts @@ -47,14 +47,14 @@ function sym(name: string): TestSymbol { function mod( name: string, kind: "package" | "module", - opts: { coverage: number; symbols?: readonly TestSymbol[]; errors?: number; warnings?: number }, + opts: { coverage: number; symbols?: readonly TestSymbol[]; errors?: number; warnings?: number; path?: string }, ): TestModule { return { name, kind, symbols: opts.symbols ?? [], coveragePercent: opts.coverage, - path: `/ws/${name.split(".").join("/")}.py`, + path: opts.path ?? `/ws/${name.split(".").join("/")}.py`, errors: opts.errors ?? 0, warnings: opts.warnings ?? 0, adopted: false, @@ -222,33 +222,73 @@ suite("Module Explorer tree structure [EXTACT-MODULES-TREE-STRUCTURE]", () => { } }); - test("flat-view sort toggle visibly reorders the module list — never a no-op (#151)", async () => { + test("flat-view exposes explicit name/path/coverage sort modes with a visible active mode (#151, #189)", async () => { const provider = new ModuleExplorerProvider(storeWith(MODULES)); try { await provider.getChildren(); provider.toggleViewMode(FAKE_CONTEXT); // -> flat - const worst = labelsOf(await provider.getChildren()); + // Default surfaces the least-typed modules first (ascending coverage). + assert.strictEqual(provider.getSortMode(), "coverage", "default flat sort is by coverage"); + const byCoverage = labelsOf(await provider.getChildren()); assert.deepStrictEqual( - worst, + byCoverage, ["app.models.user", "app.api.auth", "app.api", "app", "util"], - "worst-first orders by ascending coverage (30, 50, 80, 90, 100)", + "coverage sort orders by ascending coverage (30, 50, 80, 90, 100)", ); - provider.cycleSortMode(); // worst -> best - const best = labelsOf(await provider.getChildren()); + provider.setSortMode("name"); + const byName = labelsOf(await provider.getChildren()); assert.deepStrictEqual( - best, - ["util", "app", "app.api", "app.api.auth", "app.models.user"], - "best-first orders by descending coverage", + byName, + ["app", "app.api", "app.api.auth", "app.models.user", "util"], + "name sort orders alphabetically by dotted module name", + ); + assert.notDeepStrictEqual(byName, byCoverage, "switching sort must change the rendered order"); + + provider.setSortMode("path"); + assert.strictEqual(provider.getSortMode(), "path", "explicit selection sticks"); + + // The three modes are explicit + labelled, and the active one is marked — + // never a blind toggle (#189). + const options = provider.sortOptions(); + assert.deepStrictEqual( + options.map((option) => option.label), + ["Module Name", "Path", "Type Coverage"], + "exactly the three labelled sort modes are offered, in order", ); - assert.notDeepStrictEqual(best, worst, "toggling sort must change the rendered order"); + assert.deepStrictEqual( + options.filter((option) => option.current).map((option) => option.mode), + ["path"], + "exactly the active mode is marked current so the picker can show it", + ); + } finally { + provider.dispose(); + } + }); + + test("flat-view offers an explicit sort-by-path mode (#189)", async () => { + // Paths are chosen so file-path order (a/ < b/ < c/) differs from BOTH name + // order (alpha < beta < gamma) and score order (10 < 50 < 90) — so only a + // genuine path sort can produce [beta, alpha, gamma]. + const byPath: readonly TestModule[] = [ + mod("beta", "module", { coverage: 10, path: "/ws/a/beta.py" }), + mod("alpha", "module", { coverage: 90, path: "/ws/b/alpha.py" }), + mod("gamma", "module", { coverage: 50, path: "/ws/c/gamma.py" }), + ]; + const provider = new ModuleExplorerProvider(storeWith(byPath)); + try { + await provider.getChildren(); + provider.toggleViewMode(FAKE_CONTEXT); // -> flat + + // #189 replaces the blind worst/best/alpha cycle with explicit + // name/path/coverage modes; selecting "path" sorts by file path. + provider.setSortMode("path"); - provider.cycleSortMode(); // best -> alpha assert.deepStrictEqual( labelsOf(await provider.getChildren()), - ["app", "app.api", "app.api.auth", "app.models.user", "util"], - "alphabetical orders by module name", + ["beta", "alpha", "gamma"], + "path sort orders modules by file path, distinct from name/score order (#189)", ); } finally { provider.dispose();