chore: merge v2/main into main for the v2.7.0 release

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-16 09:45:30 -04:00
commit cf3023bdf8
5 changed files with 74 additions and 71 deletions

View file

@ -207,6 +207,8 @@ it — the split is the design.
`npm run pack:verify` builds, packs the real tarball, installs it into a clean `npm run pack:verify` builds, packs the real tarball, installs it into a clean
throwaway consumer, and drives the installed `mcp-inspector` bin end to end. It throwaway consumer, and drives the installed `mcp-inspector` bin end to end. It
needs **network access**, so it is a local/release check and is in neither needs **network access**, so it is a local/release check: it is **not** a
`local:gate` nor CI. Run it when touching packaging (the `files` allowlist, `local:gate` stage, and CI runs it only in the `publish` job on a published
a bundler `external` list, anything read from disk at runtime). GitHub Release — never on a push or PR. Run it when touching packaging (the
`files` allowlist, a bundler `external` list, anything read from disk at
runtime), and as its own step when cutting a release (the `release` skill).

View file

@ -36,7 +36,7 @@ other.
| Branch | `v2/chore/<ISSUE>-bump-<X-Y-Z>`, cut from `origin/v2/main` | the milestone-merge branch, cut from `origin/main` | | Branch | `v2/chore/<ISSUE>-bump-<X-Y-Z>`, cut from `origin/v2/main` | the milestone-merge branch, cut from `origin/main` |
| Base | **`v2/main`** | **`main`** | | Base | **`v2/main`** | **`main`** |
| Carries | the `npm audit` report, **any fixes the audit forces**, and the **version bump** — all three, one PR | the milestone's work, arriving whole from `v2/main`. **No commits of its own.** | | Carries | the `npm audit` report, **any fixes the audit forces**, and the **version bump** — all three, one PR | the milestone's work, arriving whole from `v2/main`. **No commits of its own.** |
| Verified by | `npm run local:gate` | `npm run local:gate` **plus** a hand-driven smoke of every contribution in the milestone, from the **production build**, written up as a **ledger artifact** | | Verified by | `npm run local:gate` | `npm run local:gate`, **plus** `npm run pack:verify` (not a gate stage), **plus** a hand-driven smoke of every contribution in the milestone, from the **production build**, written up as a **ledger artifact** |
| Merged when | reviewed and green | the ledger is reviewed by the maintainers and clean | | Merged when | reviewed and green | the ledger is reviewed by the maintainers and clean |
Then, and only then, a maintainer tags and publishes the **GitHub Release** Then, and only then, a maintainer tags and publishes the **GitHub Release**
@ -143,9 +143,16 @@ Then drive it. Work from a **dedicated worktree** with its own full
`npm install` (a symlinked `node_modules` passes lint and tests and then fails `npm install` (a symlinked `node_modules` passes lint and tests and then fails
every story file), run `npm run local:gate` there, and exercise the app from the every story file), run `npm run local:gate` there, and exercise the app from the
**production build** — the packaged bin and the built bundles, not `vite dev`. **production build** — the packaged bin and the built bundles, not `vite dev`.
The `local-dev`, `test-servers` and `pre-push-gate` skills cover the mechanics; The `local-dev`, `test-servers` and `pre-push-gate` skills cover the mechanics.
`pack:verify` inside the gate is what proves the tarball a consumer installs
actually resolves. **Then run `npm run pack:verify` there as its own step.** It is what proves the
tarball a consumer installs actually resolves, and ⚠️ **`local:gate` does not
run it** — `local:gate:stages` has no packaging stage, and a green gate says
nothing about the published tarball (#2380). CI runs it only in the `publish`
job, which fires on the published GitHub Release — after the tag exists — so
skipping it here means the first signal of a broken package arrives too late to
stop the release. It needs network access; record its result (tarball size and
the `pack:verify OK` line) for the ledger below.
**Every contribution closed in the milestone gets driven, not read.** The bar is **Every contribution closed in the milestone gets driven, not read.** The bar is
observed behavior from the running app — a rendered panel, a status attribute, a observed behavior from the running app — a rendered panel, a status attribute, a
@ -165,11 +172,13 @@ and link it from PR 2. Shape it like the
standfirst saying what tree was tested and that its hash matches standfirst saying what tree was tested and that its hash matches
`origin/v2/main`, plus whether the milestone payload is complete (the only `origin/v2/main`, plus whether the milestone payload is complete (the only
issue left open should be the merge itself). issue left open should be the merge itself).
- **Verdict band** — `local:gate` result, milestone issues verified as `N / N`, - **Verdict band** — `local:gate` and `pack:verify` results, milestone issues
distinct test count, regressions found. verified as `N / N`, distinct test count, regressions found.
- **The automated gate** — one cell per stage with its number (file counts, - **The automated gate** — one cell per `local:gate` stage with its number
test counts, smoke count, `pack:verify` size), and a note on what is new this (file counts, test counts, smoke count), and a note on what is new this
milestone. milestone.
- **The packaging check** — `pack:verify` in its own cell, apart from the gate
stages because it is not one of them: its result and the tarball size.
- **One section per theme**, each a table of *Issue · What was driven · - **One section per theme**, each a table of *Issue · What was driven ·
Observed · Status*. One row per closed issue, issue-linked, with the actual Observed · Status*. One row per closed issue, issue-linked, with the actual
output in the Observed cell. output in the Observed cell.

View file

@ -46,7 +46,7 @@
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-storybook": "^10.5.5", "eslint-plugin-storybook": "^10.5.5",
"happy-dom": "^20.9.0", "happy-dom": "^20.11.2",
"playwright": "^1.58.2", "playwright": "^1.58.2",
"storybook": "^10.5.5", "storybook": "^10.5.5",
"tsup": "^8.5.1", "tsup": "^8.5.1",
@ -5264,9 +5264,9 @@
"optional": true "optional": true
}, },
"node_modules/happy-dom": { "node_modules/happy-dom": {
"version": "20.11.1", "version": "20.14.5",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz",
"integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View file

@ -70,7 +70,7 @@
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-storybook": "^10.5.5", "eslint-plugin-storybook": "^10.5.5",
"happy-dom": "^20.9.0", "happy-dom": "^20.11.2",
"playwright": "^1.58.2", "playwright": "^1.58.2",
"storybook": "^10.5.5", "storybook": "^10.5.5",
"tsup": "^8.5.1", "tsup": "^8.5.1",

View file

@ -1,5 +1,5 @@
import { createRef, StrictMode } from "react"; import { createRef, StrictMode } from "react";
import { act, waitFor } from "@testing-library/react"; import { act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { CallToolResult, Tool } from "@modelcontextprotocol/client"; import type { CallToolResult, Tool } from "@modelcontextprotocol/client";
@ -79,6 +79,34 @@ async function flushAsync(): Promise<void> {
}); });
} }
// Runs `mutate` and resolves once every MutationObserver that was already
// watching `data-mantine-color-scheme` on `documentElement` has had its
// callback delivered — which is what the AppRenderer theme push rides on.
//
// This is an awaited condition rather than a wall-clock budget, per #2384.
// A probe observer registered *after* the component's is notified after it:
// observers are queued in registration order and each delivery is an ordinary
// microtask, so by the time the probe's callback runs the component's has
// already run and the assertion below is settled. No polling and no timeout is
// involved, so nothing here is load-sensitive.
async function flipThemeAttributeAndAwaitObservers(
value: string,
): Promise<void> {
await act(async () => {
await new Promise<void>((resolve) => {
const probe = new MutationObserver(() => {
probe.disconnect();
resolve();
});
probe.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-mantine-color-scheme"],
});
document.documentElement.setAttribute("data-mantine-color-scheme", value);
});
});
}
describe("AppRenderer", () => { describe("AppRenderer", () => {
it("renders an iframe with the sandbox path and tool title", () => { it("renders an iframe with the sandbox path and tool title", () => {
const bridge = createMockBridge(); const bridge = createMockBridge();
@ -538,47 +566,17 @@ describe("AppRenderer", () => {
// flip we trigger below. // flip we trigger below.
bridge.sendHostContextChange.mockClear(); bridge.sendHostContextChange.mockClear();
await act(async () => { await flipThemeAttributeAndAwaitObservers("dark");
document.documentElement.setAttribute(
"data-mantine-color-scheme", expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
"dark", expect.objectContaining({
); theme: "dark",
// MutationObserver callbacks are delivered on a microtask. styles: expect.objectContaining({
await Promise.resolve(); variables: expect.objectContaining({
}); "--color-background-primary": "#101113",
// `waitFor`, not a bare assertion after the single microtask above: how
// many ticks happy-dom takes to deliver the MutationObserver callback is
// load-dependent, so under a full parallel run the observer can still be
// pending here. Observed failing exactly once that way, with 0 calls.
//
// The default 1000ms then turned out not to be enough either — it failed
// twice in a row in `npm run local:gate` (at ~1021ms, still 0 calls) while
// passing every time this file runs alone, which is the signature of a
// budget that is too tight rather than of a broken observer. Raised
// rather than retried: a `waitFor` that is generous costs nothing on the
// passing path, since it returns as soon as the assertion holds.
//
// #2323 briefly deleted this argument as one of its "a budget inside an
// equal budget can never be observed" cases, which it was: 5000 inside a
// 5000ms test could never win. That is fixed from the other end now — the
// enclosing budget is 15000 — so the raise is observable again and has to
// stay, because the value it beats is a measured failure rather than a
// default. It does NOT ride on `asyncUtilTimeout`, which is pinned at
// 1000 precisely because raising it globally was measured worse
// (Copilot).
await waitFor(
() =>
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
expect.objectContaining({
theme: "dark",
styles: expect.objectContaining({
variables: expect.objectContaining({
"--color-background-primary": "#101113",
}),
}),
}), }),
), }),
{ timeout: 5000 }, }),
); );
} finally { } finally {
getComputedStyleSpy.mockRestore(); getComputedStyleSpy.mockRestore();
@ -599,13 +597,10 @@ describe("AppRenderer", () => {
// No `initialized` emitted — the theme observer is gated, like the // No `initialized` emitted — the theme observer is gated, like the
// container and displayMode pushes, so a pre-handshake flip is dropped. // container and displayMode pushes, so a pre-handshake flip is dropped.
bridge.sendHostContextChange.mockClear(); bridge.sendHostContextChange.mockClear();
await act(async () => { // Same awaited-delivery helper as the positive case: it is what makes this
document.documentElement.setAttribute( // "not called" a real gate rather than a vacuous pass on a flip that was
"data-mantine-color-scheme", // never delivered at all.
"dark", await flipThemeAttributeAndAwaitObservers("dark");
);
await Promise.resolve();
});
expect(bridge.sendHostContextChange).not.toHaveBeenCalled(); expect(bridge.sendHostContextChange).not.toHaveBeenCalled();
document.documentElement.removeAttribute("data-mantine-color-scheme"); document.documentElement.removeAttribute("data-mantine-color-scheme");
}); });
@ -627,13 +622,10 @@ describe("AppRenderer", () => {
}); });
bridge.sendHostContextChange.mockClear(); bridge.sendHostContextChange.mockClear();
await act(async () => { // Same awaited-delivery helper as the positive case: it is what makes this
document.documentElement.setAttribute( // "not called" a real gate rather than a vacuous pass on a flip that was
"data-mantine-color-scheme", // never delivered at all.
"dark", await flipThemeAttributeAndAwaitObservers("dark");
);
await Promise.resolve();
});
expect(bridge.sendHostContextChange).not.toHaveBeenCalled(); expect(bridge.sendHostContextChange).not.toHaveBeenCalled();
document.documentElement.removeAttribute("data-mantine-color-scheme"); document.documentElement.removeAttribute("data-mantine-color-scheme");
}); });