From d23fb96eb7f94c5baf97121e1ed0e0b59dc57197 Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 23 Oct 2026 23:08:53 +0200 Subject: [PATCH 1/7] fix(examples): resolve GitHub refs using compact SHA responses --- docs/INSTALL_LOCAL.md | 12 ++++ docs/INSTALL_SERVER.md | 12 ++++ docs/SECURITY.md | 6 ++ .../examples/github-revision-resolver.ts | 19 +++--- server/services/examples/http-provider.ts | 36 ++++++++--- ...ot_function_definition_ExternalExamples.md | 7 ++- .../server/examples/github-api-budget.test.ts | 4 +- .../github-compact-resolution.test.ts | 31 ++++++++++ .../examples/github-revision-resolver.test.ts | 3 +- .../http-provider-representation.test.ts | 61 +++++++++++++++++++ 10 files changed, 167 insertions(+), 24 deletions(-) create mode 100644 tests/server/examples/github-compact-resolution.test.ts create mode 100644 tests/server/examples/http-provider-representation.test.ts diff --git a/docs/INSTALL_LOCAL.md b/docs/INSTALL_LOCAL.md index 673813caa..ac31d6193 100644 --- a/docs/INSTALL_LOCAL.md +++ b/docs/INSTALL_LOCAL.md @@ -118,3 +118,15 @@ Docker-Sandbox-Tests sind ein getrenntes Gate und werden unter - Port belegt: `lsof -nP -iTCP:3000 -sTCP:LISTEN` prüfen. - Docker-Fehler im lokalen Profil weisen auf eine falsche Konfiguration hin; lokale Entwicklung verwendet Docker nicht. + +### Diagnose: Beispiele und GitHub-Commit-Auflösung + +Die Ref-Auflösung fordert mit `Accept: application/vnd.github.sha` nur den +Commit-SHA an (eigenes Antwortlimit: 256 Bytes). Große Commit-Diffs beeinflussen +diesen Abruf nicht. `UNOSIM_EXAMPLES_MAX_MANIFEST_BYTES` begrenzt das eigentliche +Manifest, standardmäßig auf 262.144 Bytes. Eine nur wegen großer Commit-Antworten +gesetzte Erhöhung dieser Einstellung kann nach diesem Update entfernt werden; +eine für tatsächlich größere Manifeste benötigte Einstellung bleibt bestehen. +Konfigurationsänderungen erfordern einen Serverneustart bzw. die Neuerzeugung +des Backend-Containers. Bei Quellfehlern die sichere `resolve-ref`-Diagnose im +Backend-Log und die Erreichbarkeit von GitHub prüfen. diff --git a/docs/INSTALL_SERVER.md b/docs/INSTALL_SERVER.md index 8d1551143..e2620ee8c 100644 --- a/docs/INSTALL_SERVER.md +++ b/docs/INSTALL_SERVER.md @@ -423,3 +423,15 @@ Datei neu (geprüft am 23.10.2026). Nach dem Rollback laufen alle Sketches im Host-Mock, Nano und Mega 2560 sind nicht mehr wählbar. Die vollständigen Gates stehen in [`RELEASE_RUNBOOK.md`](RELEASE_RUNBOOK.md). + +### Diagnose: Beispiele und GitHub-Commit-Auflösung + +Die Ref-Auflösung fordert mit `Accept: application/vnd.github.sha` nur den +Commit-SHA an (eigenes Antwortlimit: 256 Bytes). Große Commit-Diffs beeinflussen +diesen Abruf nicht. `UNOSIM_EXAMPLES_MAX_MANIFEST_BYTES` begrenzt das eigentliche +Manifest, standardmäßig auf 262.144 Bytes. Eine nur wegen großer Commit-Antworten +gesetzte Erhöhung dieser Einstellung kann nach diesem Update entfernt werden; +eine für tatsächlich größere Manifeste benötigte Einstellung bleibt bestehen. +Konfigurationsänderungen erfordern einen Serverneustart bzw. die Neuerzeugung +des Backend-Containers. Bei Quellfehlern die sichere `resolve-ref`-Diagnose im +Backend-Log und die Erreichbarkeit von GitHub prüfen. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 40aa00c21..5e4dc4315 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -194,6 +194,12 @@ development startup reject the flag. no client-created or changed sketches. - External examples are fetched only from configured allowed hosts, validated as a complete snapshot and bound to a resolved commit. +- GitHub ref lookups request only `application/vnd.github.sha`, enforce an + independent 256-byte response limit and validate the complete lowercase + 40-character SHA. Manifest limits remain independent. DNS, allowlist, + redirect, timeout and streaming-size checks apply to this representation too. + Ref failures log only the request ID and safe error metadata, never bodies + or upstream error messages. - GitHub ref lookups respect the REST quota: after a 403/429 rate-limit answer no request is sent before the announced reset, and browser overrides leave a reserve of 10 lookups to the operator's default Course. The optional diff --git a/server/services/examples/github-revision-resolver.ts b/server/services/examples/github-revision-resolver.ts index 7bbcd677a..73f3f7c6a 100644 --- a/server/services/examples/github-revision-resolver.ts +++ b/server/services/examples/github-revision-resolver.ts @@ -1,15 +1,16 @@ -import { z } from "zod"; +import { Logger } from "@shared/logger"; import type { ExamplesRef, FullCommitSha, RepositorySlug } from "@shared/examples"; import { fullCommitShaSchema } from "@shared/examples"; -import { config } from "../../config"; +import { safeErrorLogMetadata } from "../safe-error-log-metadata"; import type { RequestContext, RevisionResolver } from "./source-provider"; import { ExamplesError } from "./examples-error"; import type { TextFetcher } from "./http-provider"; import { githubApiBudget, type GitHubApiBudget } from "./github-api-budget"; -const githubCommitResponseSchema = z.object({ sha: fullCommitShaSchema }).passthrough(); +const SHA_RESPONSE_MAX_BYTES = 256; export class GitHubRevisionResolver implements RevisionResolver { + private readonly logger = new Logger("GitHubRevisionResolver"); constructor( private readonly fetcher: TextFetcher, private readonly budget: Pick = githubApiBudget, @@ -28,21 +29,19 @@ export class GitHubRevisionResolver implements RevisionResolver { this.budget.assertMayCall(context.sourcePriority ?? "default"); let text: string; try { - text = await this.fetcher.fetchText(url, config.examples.maxManifestBytes, context.signal); - } catch { + text = await this.fetcher.fetchText(url, SHA_RESPONSE_MAX_BYTES, context.signal, { representation: "github-commit-sha" }); + } catch (error) { + this.logger.warn(`resolve-ref request=${JSON.stringify(context.requestId)}: ${safeErrorLogMetadata(error)}`); const blockedForMs = this.budget.blockedForMs(); if (blockedForMs > 0) { throw new ExamplesError("RATE_LIMITED", "GitHub API rate limit reached for external examples", Math.ceil(blockedForMs / 1000)); } throw new ExamplesError("SOURCE_UNAVAILABLE", "External examples ref is unavailable"); } - let decoded: unknown; - try { decoded = JSON.parse(text) as unknown; } - catch { throw new ExamplesError("SOURCE_UNAVAILABLE", "External examples ref response is invalid"); } - const parsed = githubCommitResponseSchema.safeParse(decoded); + const parsed = fullCommitShaSchema.safeParse(text.trim()); if (!parsed.success) { throw new ExamplesError("SOURCE_UNAVAILABLE", "External examples ref response is invalid"); } - return parsed.data.sha; + return parsed.data; } } diff --git a/server/services/examples/http-provider.ts b/server/services/examples/http-provider.ts index df6c06898..0132cfc1c 100644 --- a/server/services/examples/http-provider.ts +++ b/server/services/examples/http-provider.ts @@ -8,15 +8,26 @@ import { githubApiBudget } from "./github-api-budget"; export type LoadedRevisionSnapshot = LoadedCourseContentSnapshot; +export interface TextFetchOptions { + readonly representation?: "github-commit-sha"; +} + +export class ExamplesResponseTooLargeError extends Error { + constructor() { + super("Examples response exceeds size limit"); + this.name = "ExamplesResponseTooLargeError"; + } +} + export interface TextFetcher { - fetchText(url: URL, maxBytes: number, signal?: AbortSignal): Promise; + fetchText(url: URL, maxBytes: number, signal?: AbortSignal, options?: TextFetchOptions): Promise; } export class SecureExamplesFetcher implements TextFetcher { constructor(private readonly controller?: ExamplesLoadController) {} - fetchText(url: URL, maxBytes: number, signal?: AbortSignal): Promise { - const operation = () => fetchText(url, maxBytes, signal); + fetchText(url: URL, maxBytes: number, signal?: AbortSignal, options?: TextFetchOptions): Promise { + const operation = () => fetchText(url, maxBytes, signal, options); return this.controller ? this.controller.runOutbound(operation, signal) : operation(); } } @@ -53,9 +64,12 @@ export function validateSourceUrl(value: string): URL { const GITHUB_API_HOST = "api.github.com"; -async function fetchText(url: URL, maxBytes: number, requestSignal?: AbortSignal): Promise { +async function fetchText(url: URL, maxBytes: number, requestSignal?: AbortSignal, options?: TextFetchOptions): Promise { requestSignal?.throwIfAborted(); const validated = validateSourceUrl(url.toString()); + if (options?.representation === "github-commit-sha" && ( + validated.hostname !== GITHUB_API_HOST || !/^\/repos\/[^/]+\/[^/]+\/commits\/[^/]+$/.test(validated.pathname) + )) throw new Error("SHA representation requires a GitHub commit URL"); await assertPublicHost(validated.hostname); requestSignal?.throwIfAborted(); const controller = new AbortController(); @@ -64,11 +78,11 @@ async function fetchText(url: URL, maxBytes: number, requestSignal?: AbortSignal else requestSignal?.addEventListener("abort", abort, { once: true }); const timeout = setTimeout(() => controller.abort(), config.examples.timeoutMs); try { - const response = await requestSource(validated, controller.signal); + const response = await requestSource(validated, controller.signal, options); if (response.status >= 300 && response.status < 400) throw new Error("Redirects are not allowed for examples"); if (!response.ok) throw new Error(`Examples source returned ${response.status}`); const contentLength = response.headers.get("content-length"); - if (contentLength && Number(contentLength) > maxBytes) throw new Error("Examples response exceeds size limit"); + if (contentLength && Number(contentLength) > maxBytes) throw new ExamplesResponseTooLargeError(); if (!response.body) throw new Error("Examples response has no body"); return await readLimitedText(response.body, maxBytes); } finally { @@ -77,14 +91,18 @@ async function fetchText(url: URL, maxBytes: number, requestSignal?: AbortSignal } } -async function requestSource(url: URL, signal: AbortSignal): Promise { +async function requestSource(url: URL, signal: AbortSignal, options?: TextFetchOptions): Promise { const isGitHubApi = url.hostname.toLowerCase() === GITHUB_API_HOST; // The optional token is sent to the GitHub REST API only, never to raw content hosts. const token = isGitHubApi ? config.examples.githubToken : undefined; + const headers = { + ...(options?.representation === "github-commit-sha" ? { accept: "application/vnd.github.sha" } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; const response = await fetch(url, { signal, redirect: "manual", - ...(token ? { headers: { authorization: `Bearer ${token}` } } : {}), + ...(Object.keys(headers).length ? { headers } : {}), }); if (isGitHubApi) githubApiBudget.observe(response.status, response.headers); return response; @@ -100,7 +118,7 @@ async function readLimitedText(body: ReadableStream, maxBytes: numbe total += next.value.byteLength; if (total > maxBytes) { await reader.cancel(); - throw new Error("Examples response exceeds size limit"); + throw new ExamplesResponseTooLargeError(); } chunks.push(next.value); } diff --git a/ssot/ssot_function_definition_ExternalExamples.md b/ssot/ssot_function_definition_ExternalExamples.md index 2a729c6b6..4012254a5 100644 --- a/ssot/ssot_function_definition_ExternalExamples.md +++ b/ssot/ssot_function_definition_ExternalExamples.md @@ -182,8 +182,11 @@ geliefert wird. Für öffentliche GitHub-Repositories löst der Server den validierten Ref über den serverkontrollierten GitHub-Endpunkt `GET https://api.github.com/repos///commits/` auf. Die -Antwort MUSS einen vollständigen kleingeschriebenen SHA gemäß -`^[0-9a-f]{40}$` liefern. Browserinput bestimmt weder Host noch URL-Struktur. +Anfrage MUSS `Accept: application/vnd.github.sha` verwenden. Die Textantwort +MUSS nach Entfernen von Rand-Whitespace einen vollständigen kleingeschriebenen +SHA gemäß `^[0-9a-f]{40}$` liefern. Ihr eigenes festes Antwortlimit beträgt +256 Bytes, unabhängig vom Manifestlimit. Die vollständige Commit-JSON-Antwort +mit Dateidiffs wird weder angefordert noch als Ausweichformat akzeptiert. Browserinput bestimmt weder Host noch URL-Struktur. Danach gelten ausschließlich folgende Content-Ziele: diff --git a/tests/server/examples/github-api-budget.test.ts b/tests/server/examples/github-api-budget.test.ts index 22a44cc2f..7fdb16547 100644 --- a/tests/server/examples/github-api-budget.test.ts +++ b/tests/server/examples/github-api-budget.test.ts @@ -59,7 +59,7 @@ describe("GitHub revision resolution under a rate limit", () => { it("sends no request while blocked and reports RATE_LIMITED with the remaining wait", async () => { const budget = new GitHubApiBudget(() => nowMs); budget.observe(403, headers({ "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(nowMs / 1000 + 300) })); - const fetchText = vi.fn(async () => JSON.stringify({ sha: revision })); + const fetchText = vi.fn(async () => revision); const resolver = new GitHubRevisionResolver({ fetchText }, budget); await expect(resolver.resolve("owner/repo", "main", context)).rejects.toMatchObject({ code: "RATE_LIMITED", retryAfterSeconds: 300 }); @@ -71,7 +71,7 @@ describe("GitHub revision resolution under a rate limit", () => { const budget = new GitHubApiBudget(() => now); let exhausted = false; const fetchText = vi.fn(async () => { - if (!exhausted) return JSON.stringify({ sha: revision }); + if (!exhausted) return revision; budget.observe(403, headers({ "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(now / 1000 + 1_800) })); throw new Error("Examples source returned 403"); }); diff --git a/tests/server/examples/github-compact-resolution.test.ts b/tests/server/examples/github-compact-resolution.test.ts new file mode 100644 index 000000000..add89a587 --- /dev/null +++ b/tests/server/examples/github-compact-resolution.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import { Logger } from "@shared/logger"; +import { GitHubRevisionResolver } from "../../../server/services/examples/github-revision-resolver"; + +const sha = "a".repeat(40); +const context = { identity: "user", requestId: "diagnostic-request" }; + +describe("compact GitHub revision resolution", () => { + it.each(["main", "v1.2.3", sha])("requests a bounded SHA representation for %s", async (ref) => { + const fetchText = vi.fn(async () => `${sha}\n`); + await expect(new GitHubRevisionResolver({ fetchText }).resolve("owner/repo", ref, context)).resolves.toBe(sha); + expect(fetchText).toHaveBeenCalledWith(new URL(`https://api.github.com/repos/owner/repo/commits/${ref}`), 256, undefined, { representation: "github-commit-sha" }); + }); + + it.each(["short", "A".repeat(40), "a".repeat(257), JSON.stringify({ sha })])("rejects malformed SHA text without a JSON fallback", async (text) => { + const fetchText = vi.fn(async () => text); + await expect(new GitHubRevisionResolver({ fetchText }).resolve("owner/repo", "main", context)).rejects.toMatchObject({ code: "SOURCE_UNAVAILABLE" }); + expect(fetchText).toHaveBeenCalledOnce(); + }); + + it("records safe diagnostics without copying an upstream secret", async () => { + const warn = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); + try { + const fetchText = vi.fn(async () => { throw new Error("upstream-secret-content"); }); + await expect(new GitHubRevisionResolver({ fetchText }).resolve("owner/repo", "main", context)).rejects.toMatchObject({ code: "SOURCE_UNAVAILABLE" }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("resolve-ref")); + expect(warn.mock.calls.flat().join(" ")).toContain(context.requestId); + expect(warn.mock.calls.flat().join(" ")).not.toContain("upstream-secret-content"); + } finally { warn.mockRestore(); } + }); +}); diff --git a/tests/server/examples/github-revision-resolver.test.ts b/tests/server/examples/github-revision-resolver.test.ts index c978017de..69c873a49 100644 --- a/tests/server/examples/github-revision-resolver.test.ts +++ b/tests/server/examples/github-revision-resolver.test.ts @@ -5,7 +5,7 @@ const revision = "a".repeat(40); describe("GitHub revision resolver", () => { it("resolves a movable ref through the credential-free public commits API", async () => { - const fetchText = vi.fn(async () => JSON.stringify({ sha: revision })); + const fetchText = vi.fn(async () => revision); const resolver = new GitHubRevisionResolver({ fetchText }); await expect(resolver.resolve("owner/repo", "main", { identity: "user", requestId: "request" })) .resolves.toBe(revision); @@ -13,6 +13,7 @@ describe("GitHub revision resolver", () => { new URL("https://api.github.com/repos/owner/repo/commits/main"), expect.any(Number), undefined, + { representation: "github-commit-sha" }, ); }); diff --git a/tests/server/examples/http-provider-representation.test.ts b/tests/server/examples/http-provider-representation.test.ts new file mode 100644 index 000000000..5694fdbec --- /dev/null +++ b/tests/server/examples/http-provider-representation.test.ts @@ -0,0 +1,61 @@ +import dns from "node:dns/promises"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { config } from "../../../server/config"; +import { SecureExamplesFetcher } from "../../../server/services/examples/http-provider"; +import { GitHubRevisionResolver } from "../../../server/services/examples/github-revision-resolver"; + +vi.mock("node:dns/promises", () => ({ default: { lookup: vi.fn().mockResolvedValue([{ address: "140.82.121.3", family: 4 }]) } })); +const sha = "a".repeat(40); +const url = new URL("https://api.github.com/repos/owner/repo/commits/main"); +const options = { representation: "github-commit-sha" } as const; +const originalToken = config.examples.githubToken; + +afterEach(() => { + vi.unstubAllGlobals(); + config.examples.githubToken = originalToken; + vi.mocked(dns.lookup).mockResolvedValue([{ address: "140.82.121.3", family: 4 }] as never); +}); + +describe("GitHub SHA HTTP representation", () => { + it("resolves a SHA even when the alternative JSON commit exceeds the manifest budget", async () => { + const fetchMock = vi.fn(async (_url: URL, init?: RequestInit) => new Response( + new Headers(init?.headers).get("accept") === "application/vnd.github.sha" ? sha : JSON.stringify({ sha, patch: "x".repeat(300_000) }), + )); + vi.stubGlobal("fetch", fetchMock); + await expect(new GitHubRevisionResolver(new SecureExamplesFetcher()).resolve("owner/repo", "main", { identity: "user", requestId: "request" })).resolves.toBe(sha); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("accept")).toBe("application/vnd.github.sha"); + }); + + it.each(["https://raw.githubusercontent.com/owner/repo/main/file.ino", "https://api.github.com/rate_limit"])("refuses the SHA representation outside a commit URL: %s", async (input) => { + const fetchMock = vi.fn(async () => new Response(sha)); + vi.stubGlobal("fetch", fetchMock); + await expect(new SecureExamplesFetcher().fetchText(new URL(input), 256, undefined, options)).rejects.toThrow(/representation/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("combines the SHA header with the API token without forwarding either to raw content", async () => { + config.examples.githubToken = "test-token"; + const fetchMock = vi.fn(async () => new Response(sha)); + vi.stubGlobal("fetch", fetchMock); + const fetcher = new SecureExamplesFetcher(); + await fetcher.fetchText(url, 256, undefined, options); + await fetcher.fetchText(new URL("https://raw.githubusercontent.com/owner/repo/main/file.ino"), 256); + expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("accept")).toBe("application/vnd.github.sha"); + expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("authorization")).toBe("Bearer test-token"); + expect(new Headers(fetchMock.mock.calls[1][1]?.headers).has("accept")).toBe(false); + expect(new Headers(fetchMock.mock.calls[1][1]?.headers).has("authorization")).toBe(false); + }); + + it.each([true, false])("identifies oversized SHA responses (content-length: %s)", async (declared) => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("x".repeat(257), declared ? { headers: { "content-length": "257" } } : undefined))); + await expect(new SecureExamplesFetcher().fetchText(url, 256, undefined, options)).rejects.toMatchObject({ name: "ExamplesResponseTooLargeError" }); + }); + + it("does not send an already-aborted SHA request", async () => { + const controller = new AbortController(); controller.abort(); + const fetchMock = vi.fn(async () => new Response(sha)); vi.stubGlobal("fetch", fetchMock); + await expect(new SecureExamplesFetcher().fetchText(url, 256, controller.signal, options)).rejects.toMatchObject({ name: "AbortError" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); From da851b4364d661ead75e0eafeb1f2589cfd6cf3f Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 23 Oct 2026 23:11:01 +0200 Subject: [PATCH 2/7] feat(examples): expose a source-independent built-in catalog --- docs/ARCHITECTURE.md | 11 ++++++++++ docs/SECURITY.md | 6 ++++++ .../0005-browser-scoped-external-examples.md | 9 ++++++++ server/routes/examples.routes.ts | 13 +++++++++++- .../services/examples/examples-repository.ts | 14 ++++++++----- ...ot_function_definition_ExternalExamples.md | 7 +++++++ tests/server/examples/builtin-catalog.test.ts | 21 +++++++++++++++++++ tests/server/routes/examples.routes.test.ts | 20 ++++++++++++++++-- 8 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 tests/server/examples/builtin-catalog.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8a3a2eccf..16023fb50 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -455,3 +455,14 @@ Diese Metriken sind über `/api/status` und WebSocket-Events verfügbar. `/api/h - [`../ssot/ssot_function_definition_ExternalExamples.md`](../ssot/ssot_function_definition_ExternalExamples.md) – Fachlicher Examples-Vertrag - [`CAPACITY_HISTORICAL_PHASE_3_4.md`](CAPACITY_HISTORICAL_PHASE_3_4.md) – historische Kapazitätsmessung; aktuelle Werte in [`CAPACITY_MODEL.md`](CAPACITY_MODEL.md) - [`TESTING_STANDARDS.md`](TESTING_STANDARDS.md) – Teststrategie und -konventionen + +### Unabhängige Katalogwege für Beispiele + +Das Beispiele-Menü lädt parallel `/api/examples/builtin/catalog` (lokale +Dateien aus `public/examples`, BuiltInProvider) und `/api/examples` (bisheriger +gemeinsamer Katalog mit externer Source-Auflösung). Es übernimmt aus dem zweiten +Ergebnis nur externe Einträge. Jede Gruppe hat eigene Lade-, Fehler- und +Retry-Zustände; lokale Beispiele warten nicht auf GitHub. Die lokale Antwort +verändert weder die externe Auswahl noch den Tutor-Kontext. Externe +Ref-Auflösung verwendet die begrenzte SHA-Repräsentation von GitHub, nicht +die vollständige Commit-Antwort. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 5e4dc4315..3f2b3e3d2 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -227,3 +227,9 @@ development startup reject the flag. Report security vulnerabilities privately to the instance operators before public disclosure. + +The read-only `/api/examples/builtin/catalog` endpoint serves only the local +BuiltInProvider catalog with the same anonymous default-read policy as +`/api/examples`. It accepts no selection parameters and does not start outbound +requests or spend source-load/GitHub quotas. External selection and revision +checks remain on their existing endpoints. diff --git a/docs/adr/0005-browser-scoped-external-examples.md b/docs/adr/0005-browser-scoped-external-examples.md index 4d2e0084f..02952366a 100644 --- a/docs/adr/0005-browser-scoped-external-examples.md +++ b/docs/adr/0005-browser-scoped-external-examples.md @@ -129,3 +129,12 @@ Syntax, API, In-Memory-Limits, Cache-Keys und Concurrency sind im normativen [`External-Examples-Vertrag`](../../ssot/ssot_function_definition_ExternalExamples.md) konkretisiert. Getrennt offen bleibt nur eine mögliche neustartfeste LKG-Ablage; sie darf Request-Isolation und Commit-Bindung nicht verändern. + +## Nachtrag 2026-10-23: Unabhängiger eingebauter Katalog + +`GET /api/examples/builtin/catalog` bietet ausschließlich lokale Beispiele ohne +GitHub-Abhängigkeit an. Das Menü lädt diesen Katalog parallel zum bisherigen +gemeinsamen Katalog und verarbeitet die Gruppen getrennt. Der gemeinsame +Katalog, seine kontrollierten Fehler sowie Source-/Revisionsbindung, LKG und +transaktionales Validate/Apply behalten ihren Vertrag. Ein externer Ausfall +aktiviert keinen anderen externen Snapshot. diff --git a/server/routes/examples.routes.ts b/server/routes/examples.routes.ts index d6f256aba..526ce4e61 100644 --- a/server/routes/examples.routes.ts +++ b/server/routes/examples.routes.ts @@ -25,7 +25,7 @@ export interface ExamplesRoutesOptions { export function registerExamplesRoutes( app: Express, - repository: Pick, + repository: Pick, options: ExamplesRoutesOptions = {}, ): void { const trust = options.trust ?? config.trust; @@ -77,6 +77,17 @@ export function registerExamplesRoutes( } }); + app.get("/api/examples/builtin/catalog", async (req, res) => { + if (Object.keys(req.query).length) { + return sendError(res, new ExamplesError("INVALID_SELECTION", "Built-in catalog accepts no query parameters")); + } + try { + res.json(await repository.getBuiltInCatalog()); + } catch (error) { + sendError(res, asExamplesError(error)); + } + }); + app.get("/api/examples/:id", async (req, res) => { const detail = parseDetailSelection(req); if (detail instanceof ExamplesError) return sendError(res, detail); diff --git a/server/services/examples/examples-repository.ts b/server/services/examples/examples-repository.ts index 3b2960e28..fbd07cf10 100644 --- a/server/services/examples/examples-repository.ts +++ b/server/services/examples/examples-repository.ts @@ -84,15 +84,19 @@ export class ExamplesRepository implements TutorCourseContentResolver { }; } + async getBuiltInCatalog(): Promise { + return this.catalog({ + selection: "default", mode: "builtin", repository: null, ref: null, + revision: null, status: "builtin", stale: false, tutor: { status: "absent" }, + }, await this.builtInProvider.getExamples()); + } + async getCatalog(selection: ExamplesRequestSelection, context: RequestContext): Promise { const resolved = resolveExamplesSelection(selection, this.examplesConfig); - const builtins = await this.builtInProvider.getExamples(); if (resolved.mode === "builtin") { - return this.catalog({ - selection: "default", mode: "builtin", repository: null, ref: null, - revision: null, status: "builtin", stale: false, tutor: { status: "absent" }, - }, builtins); + return this.getBuiltInCatalog(); } + const builtins = await this.builtInProvider.getExamples(); const remote = await this.sourceProvider.resolve(resolved.repository, resolved.ref, this.withPriority(context, resolved), false); return this.catalog({ selection: resolved.selection, diff --git a/ssot/ssot_function_definition_ExternalExamples.md b/ssot/ssot_function_definition_ExternalExamples.md index 4012254a5..45431ed35 100644 --- a/ssot/ssot_function_definition_ExternalExamples.md +++ b/ssot/ssot_function_definition_ExternalExamples.md @@ -122,11 +122,18 @@ Der Browser kontaktiert ausschließlich UnoSim: ```http POST /api/examples/validate +GET /api/examples/builtin/catalog GET /api/examples GET /api/examples?repository=owner%2Frepository&ref=main GET /api/examples/:id?repository=owner%2Frepository&revision= ``` +- `GET /api/examples/builtin/catalog` liefert ausschließlich eingebaute Beispiele + mit `mode: builtin`, `status: builtin`, `revision: null` und `stale: false`. + Der Endpoint akzeptiert keine Query-Parameter, verwendet nur den lokalen + Provider und benötigt weder Ref-Auflösung noch externe Load-/GitHub-Kontingente. + Es gelten dieselben anonymen Default-Leserechte wie beim gemeinsamen Katalog. + Die unabhängige Verfügbarkeit ist kein Wechsel der externen Source. - Fehlende Auswahlparameter beim Katalog bedeuten Server-Default. - Ein Override MUSS `repository` und `ref` gemeinsam übertragen. - Partielle, doppelte und unbekannte Parameter werden abgelehnt. diff --git a/tests/server/examples/builtin-catalog.test.ts b/tests/server/examples/builtin-catalog.test.ts new file mode 100644 index 000000000..010052e82 --- /dev/null +++ b/tests/server/examples/builtin-catalog.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; +import { ExamplesRepository } from "../../../server/services/examples/examples-repository"; + +describe("independent built-in catalog", () => { + it("lists local examples without starting a failing or hanging external operation", async () => { + const resolve = vi.fn(async () => { throw new Error("GitHub unavailable"); }); + const getRevision = vi.fn(() => new Promise(() => undefined)); + const repository = new ExamplesRepository({ + builtInProvider: { getExamples: async () => ["blink", "input"].map(id => ({ + id: `builtin-${id}`, title: id, category: "Built-in", main: `${id}.ino`, source: "builtin" as const, + files: [{ name: `${id}.ino`, path: `${id}.ino`, content: "void setup(){} void loop(){}" }], + })) }, + sourceProvider: { resolve, getRevision }, + }); + const catalog = await repository.getBuiltInCatalog(); + expect(catalog.source).toMatchObject({ mode: "builtin", repository: null, ref: null, revision: null, status: "builtin", stale: false }); + expect(catalog.examples.map(e => e.id)).toEqual(["builtin-blink", "builtin-input"]); + expect(catalog.examples[0].files[0]).not.toHaveProperty("content"); + expect(resolve).not.toHaveBeenCalled(); expect(getRevision).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/server/routes/examples.routes.test.ts b/tests/server/routes/examples.routes.test.ts index 59dee0f37..ef51f2daa 100644 --- a/tests/server/routes/examples.routes.test.ts +++ b/tests/server/routes/examples.routes.test.ts @@ -53,9 +53,14 @@ describe("request-scoped examples routes", () => { schemaVersion: 1 as const, id, title: "Example", category: "Test", main: "main.ino", source: "external" as const, revision: requestedRevision ?? null, files: [{ name: "main.ino", path: "main.ino", content: "code" }], })); + const getBuiltInCatalog = vi.fn(async () => ({ + schemaVersion: 1 as const, + source: { selection: "default" as const, mode: "builtin" as const, repository: null, ref: null, revision: null, status: "builtin" as const, stale: false as const }, + examples: [], + })); const app = express(); app.use(express.json()); - registerExamplesRoutes(app, { validate, getCatalog, getExample }, { + registerExamplesRoutes(app, { validate, getCatalog, getExample, getBuiltInCatalog }, { trust: gateway, validateRateLimiter: new IdentityRateLimiter("test validate", { maxRequests: validateMax, windowMs: 60_000, blockDurationMs: 60_000 }), overrideRateLimiter: new IdentityRateLimiter("test override", { maxRequests: overrideMax, windowMs: 60_000, blockDurationMs: 30_000 }), @@ -63,9 +68,20 @@ describe("request-scoped examples routes", () => { server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server!.once("listening", resolve)); const address = server.address() as { port: number }; - return { baseUrl: `http://127.0.0.1:${address.port}`, validate, getCatalog, getExample }; + return { baseUrl: `http://127.0.0.1:${address.port}`, validate, getCatalog, getExample, getBuiltInCatalog }; } + it("serves the built-in catalog anonymously without consulting the external catalog", async () => { + const { baseUrl, getCatalog, getBuiltInCatalog, validate } = await start(); + getCatalog.mockRejectedValue(new Error("External source unavailable")); + const response = await request(baseUrl, "GET", "/api/examples/builtin/catalog"); + expect(response).toMatchObject({ status: 200, body: { schemaVersion: 1, source: { mode: "builtin", revision: null } } }); + expect(getBuiltInCatalog).toHaveBeenCalledOnce(); + expect(getCatalog).not.toHaveBeenCalled(); expect(validate).not.toHaveBeenCalled(); + expect((await request(baseUrl, "GET", "/api/examples/builtin/catalog?repository=owner%2Frepo&ref=main")).body.error.code).toBe("INVALID_SELECTION"); + expect(getBuiltInCatalog).toHaveBeenCalledOnce(); + }); + it("supports validate and request-scoped catalog with private no-store", async () => { const { baseUrl, validate, getCatalog } = await start(); const body = { schemaVersion: 1, selection: { repository: "owner/repo", ref: "main" } }; From e4a07a530bf4f47f6527a31c77d751b7044c5fcb Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 23 Oct 2026 23:18:16 +0200 Subject: [PATCH 3/7] fix(examples): keep built-ins usable during external failures --- .../src/components/features/examples-menu.tsx | 150 ++++++++---------- client/src/lib/external-examples.ts | 14 ++ docs/INSTALL_LOCAL.md | 8 + docs/INSTALL_SERVER.md | 8 + docs/TESTING_STANDARDS.md | 14 ++ docs/USER_GUIDE.md | 9 +- docs/proposals/browser-emulation-status.md | 8 + e2e/examples-availability.spec.ts | 58 +++++++ ...ot_function_definition_ExternalExamples.md | 19 +++ tests/client/builtin-examples.test.ts | 27 ++++ .../examples-menu.availability.test.tsx | 89 +++++++++++ tests/client/examples-menu.behavior.test.tsx | 62 ++++---- 12 files changed, 357 insertions(+), 109 deletions(-) create mode 100644 e2e/examples-availability.spec.ts create mode 100644 tests/client/builtin-examples.test.ts create mode 100644 tests/client/examples-menu.availability.test.tsx diff --git a/client/src/components/features/examples-menu.tsx b/client/src/components/features/examples-menu.tsx index d641b4ede..3ea6c129e 100644 --- a/client/src/components/features/examples-menu.tsx +++ b/client/src/components/features/examples-menu.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { DropdownMenu, DropdownMenuContent, @@ -14,8 +14,8 @@ import { type ExampleBoardPrompt, exampleBoardPrompt } from "@/lib/example-board import { ExampleBoardPromptDialog } from "@/components/features/example-board-prompt"; import { useToast } from "@/hooks/use-toast"; import { - ExternalExamplesError, refreshExternalExamplesCatalog, + loadBuiltInExamplesCatalog, setActiveExternalExampleContext, useExternalExamples, } from "@/lib/external-examples"; @@ -33,6 +33,15 @@ interface Example { revision?: string; } +interface CatalogGroup { + items: Example[]; + loading: boolean; + error: boolean; + loaded: boolean; +} + +const emptyGroup = (): CatalogGroup => ({ items: [], loading: false, error: false, loaded: false }); + interface ExampleDetail { files: Array<{ name: string; path?: string; content: string }>; } @@ -49,8 +58,8 @@ export function ExamplesMenu({ onLoadExample, capabilities = getServerCapabilities(true), }: ExamplesMenuProps) { - const [examples, setExamples] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [groups, setGroups] = useState>({ builtin: emptyGroup(), external: emptyGroup() }); + const examples = [...groups.builtin.items, ...groups.external.items]; const [loadingExampleId, setLoadingExampleId] = useState(null); const [open, setOpen] = useState(false); const [boardQuestion, setBoardQuestion] = useState<{ example: Example; prompt: ExampleBoardPrompt } | undefined>(); @@ -60,69 +69,46 @@ export function ExamplesMenu({ const { override } = useExternalExamples(); const loadGeneration = useRef(0); const capabilitiesRef = useRef(capabilities); - const previousOpenRef = useRef(false); - const previousOverrideRef = useRef(override); + const controllers = useRef>>({}); capabilitiesRef.current = capabilities; - useEffect(() => { - const opened = open && !previousOpenRef.current; - const overrideChanged = previousOverrideRef.current !== override; - previousOpenRef.current = open; - previousOverrideRef.current = override; - if (!open || (!opened && !overrideChanged)) return; + const loadGroup = useCallback(async (source: ExampleSource) => { if (!capabilitiesRef.current.canUseServerExamples) return; - const generation = ++loadGeneration.current; - let cancelled = false; - const loadExamples = async () => { - try { - setIsLoading(true); - const payload = await refreshExternalExamplesCatalog(); - if ( - cancelled || - generation !== loadGeneration.current || - !capabilitiesRef.current.canUseServerExamples - ) return; - const loadedExamples = payload.examples - .map((example) => ({ - ...example, - ...(example.source === "external" && - payload.source.mode === "repository-ref" - ? { - repository: payload.source.repository!, - ref: payload.source.ref!, - revision: payload.source.revision!, - } - : {}), - })) - .toSorted((a, b) => - `${a.source}/${a.category}/${a.title}`.localeCompare( - `${b.source}/${b.category}/${b.title}`, - ), - ); - setExamples(loadedExamples); - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") - return; - console.error("Failed to load examples:", error); - toast({ - title: "Failed to Load Examples", - description: - error instanceof ExternalExamplesError - ? error.message - : "Could not load example files", - variant: "destructive", - }); - } finally { - if (!cancelled && generation === loadGeneration.current) - setIsLoading(false); - } - }; + controllers.current[source]?.abort(); + const controller = new AbortController(); + controllers.current[source] = controller; + const generation = loadGeneration.current; + const isCurrent = () => !controller.signal.aborted && generation === loadGeneration.current + && capabilitiesRef.current.canUseServerExamples; + setGroups(current => ({ ...current, [source]: { ...emptyGroup(), loading: true } })); + try { + const payload = await (source === "builtin" + ? loadBuiltInExamplesCatalog(controller.signal) + : refreshExternalExamplesCatalog(controller.signal)); + if (!isCurrent()) return; + const items = payload.examples.filter(example => example.source === source).map(example => ({ + ...example, + ...(source === "external" && payload.source.mode === "repository-ref" ? { + repository: payload.source.repository!, ref: payload.source.ref!, revision: payload.source.revision!, + } : {}), + })); + setGroups(current => ({ ...current, [source]: { items, loading: false, error: false, loaded: true } })); + } catch (error) { + if (!isCurrent() || (error instanceof DOMException && error.name === "AbortError")) return; + setGroups(current => ({ ...current, [source]: { ...emptyGroup(), error: true } })); + } + }, []); - loadExamples(); + useEffect(() => { + if (!open || !capabilitiesRef.current.canUseServerExamples) return; + ++loadGeneration.current; + void loadGroup("builtin"); + void loadGroup("external"); return () => { - cancelled = true; + controllers.current.builtin?.abort(); + controllers.current.external?.abort(); }; - }, [open, override, toast]); + }, [open, override, loadGroup]); // Global shortcut Meta+E to toggle examples menu useEffect(() => { @@ -157,7 +143,7 @@ export function ExamplesMenu({ const getVisibleItems = () => { const all = Array.from( document.querySelectorAll( - '[data-role="example-source"], [data-role="example-folder"], [data-role="example-item"]', + '[data-role="example-source"], [data-role="example-folder"], [data-role="example-item"], [data-role="example-retry"]', ), ); return all.filter( @@ -169,7 +155,7 @@ export function ExamplesMenu({ const clearHighlight = () => { // Clear from all items including those not currently visible const allItems = document.querySelectorAll( - '[data-role="example-source"], [data-role="example-folder"], [data-role="example-item"]', + '[data-role="example-source"], [data-role="example-folder"], [data-role="example-item"], [data-role="example-retry"]', ); allItems.forEach((it) => { it.classList.remove( @@ -198,7 +184,7 @@ export function ExamplesMenu({ // Handle mouse movement - clear keyboard highlight and re-enable hover const onMouseMove = (e: MouseEvent) => { const target = (e.target as HTMLElement).closest( - '[data-role="example-source"], [data-role="example-folder"], [data-role="example-item"]', + '[data-role="example-source"], [data-role="example-folder"], [data-role="example-item"], [data-role="example-retry"]', ); if (target?.dataset.keyboardFocused === "true") { clearHighlight(); @@ -367,24 +353,28 @@ export function ExamplesMenu({
- {examples.length === 0 && !isLoading && ( -
- No examples available + {(["builtin", "external"] as const).map(source => ( +
+ {groups[source].loading && ( +
+ Loading {source === "builtin" ? "built-in" : "external"} examples... +
+ )} + {groups[source].error && ( +
+

{source === "builtin" ? "Built-in" : "External"} examples are currently unavailable. + {source === "external" && groups.builtin.items.length > 0 ? " Built-in examples are still available." : ""} +

+ +
+ )}
+ ))} + {examples.length === 0 && groups.builtin.loaded && groups.external.loaded && ( +
No examples available
)} - - {isLoading && ( -
- Loading examples... -
- )} - - {!isLoading && examples.length > 0 && ( - + {examples.length > 0 && ( + )} diff --git a/client/src/lib/external-examples.ts b/client/src/lib/external-examples.ts index 78e5d13e5..14d94aa1e 100644 --- a/client/src/lib/external-examples.ts +++ b/client/src/lib/external-examples.ts @@ -179,6 +179,20 @@ export async function validateExternalExamplesSelection( return payload.source; } +/** Loads local examples independently of selection, external request generation and Tutor state. */ +export async function loadBuiltInExamplesCatalog(signal?: AbortSignal): Promise { + const response = await fetch("/api/examples/builtin/catalog", { signal }); + if (!response.ok) throw await parseApiError(response); + const payload = await parseJson(response); + if (!isCatalog(payload) || payload.source.selection !== "default" || payload.source.mode !== "builtin" + || payload.source.status !== "builtin" || payload.source.repository !== null || payload.source.ref !== null + || payload.source.revision !== null || payload.source.stale + || !payload.examples.every(example => example && typeof example === "object" && example.source === "builtin")) { + throw new ExternalExamplesError("UNKNOWN", "The server returned an invalid built-in examples catalog."); + } + return payload; +} + export async function refreshExternalExamplesCatalog( signal?: AbortSignal, ): Promise { diff --git a/docs/INSTALL_LOCAL.md b/docs/INSTALL_LOCAL.md index ac31d6193..a7d4aa438 100644 --- a/docs/INSTALL_LOCAL.md +++ b/docs/INSTALL_LOCAL.md @@ -130,3 +130,11 @@ eine für tatsächlich größere Manifeste benötigte Einstellung bleibt bestehe Konfigurationsänderungen erfordern einen Serverneustart bzw. die Neuerzeugung des Backend-Containers. Bei Quellfehlern die sichere `resolve-ref`-Diagnose im Backend-Log und die Erreichbarkeit von GitHub prüfen. + +Der Diagnoseabruf `GET /api/examples/builtin/catalog` muss bei erreichbarem +Backend auch dann erfolgreich sein, wenn `/api/examples` wegen der externen +Quelle fehlschlägt. Er liest `public/examples` und akzeptiert keine Query-Parameter. +Das Menü lädt beide Gruppen getrennt und bietet bei Fehlern **Retry** an. +Das Hinzufügen eines Beispiel-Repositories zum IDE-Workspace stellt die Quelle +des laufenden Servers nicht um; maßgeblich bleiben Serverkonfiguration und +validierter Browser-Override. diff --git a/docs/INSTALL_SERVER.md b/docs/INSTALL_SERVER.md index e2620ee8c..758759b9b 100644 --- a/docs/INSTALL_SERVER.md +++ b/docs/INSTALL_SERVER.md @@ -435,3 +435,11 @@ eine für tatsächlich größere Manifeste benötigte Einstellung bleibt bestehe Konfigurationsänderungen erfordern einen Serverneustart bzw. die Neuerzeugung des Backend-Containers. Bei Quellfehlern die sichere `resolve-ref`-Diagnose im Backend-Log und die Erreichbarkeit von GitHub prüfen. + +Der Diagnoseabruf `GET /api/examples/builtin/catalog` muss bei erreichbarem +Backend auch dann erfolgreich sein, wenn `/api/examples` wegen der externen +Quelle fehlschlägt. Er liest `public/examples` und akzeptiert keine Query-Parameter. +Das Menü lädt beide Gruppen getrennt und bietet bei Fehlern **Retry** an. +Das Hinzufügen eines Beispiel-Repositories zum IDE-Workspace stellt die Quelle +des laufenden Servers nicht um; maßgeblich bleiben Serverkonfiguration und +validierter Browser-Override. diff --git a/docs/TESTING_STANDARDS.md b/docs/TESTING_STANDARDS.md index de649122d..a1f007db6 100644 --- a/docs/TESTING_STANDARDS.md +++ b/docs/TESTING_STANDARDS.md @@ -154,3 +154,17 @@ security defaults, or open public host bindings. 1. `npm run check` 2. `npm run test:fast` 3. `./run-tests.sh` + +## Verfügbarkeit von Beispielen + +Regressionstests für Beispiele simulieren ausgehende GitHub-Antworten +deterministisch. Die Commit-Auflösung prüft die SHA-Repräsentation, das separate +256-Byte-Limit, Header-/Token-Grenzen, Abbruch und bestehende Quota-Regeln. +API-Tests prüfen den eingebauten Katalog ohne externe Provideraufrufe. +`e2e/examples-availability.spec.ts` hält nur den gemeinsamen Katalog zurück oder +antwortet dort mit HTTP 503. Der lokale Katalog und die Blink-Datei werden vom +echten Testbackend geladen; anschließend wird der Code im Monaco-Editor geprüft. +Ein weiteres Szenario prüft Retry und Wiederherstellung ohne Editorwechsel oder +doppelten lokalen Abruf. Unit-Tests decken außerdem HTTP 429, leere Teilergebnisse, +den umgekehrten Teilausfall und verspätete Antworten nach Auswahlwechsel ab. +Ein echter GitHub-Ausfall ist keine Voraussetzung für diese Tests. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 76cfc5aaf..6bf315e84 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -37,7 +37,14 @@ Der Server speichert keine Sketches. Sichere deine Dateien mit ## Beispiele laden **Examples** in der Kopfzeile (oder `Cmd/Ctrl+E`) öffnet die Beispielsammlung. -Ein Beispiel ersetzt nach Rückfrage den aktuellen Sketch. +Ein Beispiel ersetzt den aktuellen Sketch. + +**Built-in** enthält die eingebauten Beispiele. Diese Liste wird unabhängig +von **External** geladen und bleibt bedienbar, wenn die externe Quelle langsam +oder nicht erreichbar ist. Eine Statusmeldung erklärt den Ausfall; **Retry** +lädt nur die betroffene Liste neu. Ein erneuter Abruf verändert deinen Sketch +erst, wenn du ein Beispiel auswählst. Beide Listen benötigen die Verbindung +zum UnoSim-Server. Lehrkräfte können für ihren Browser eine eigene Beispielsammlung wählen: diff --git a/docs/proposals/browser-emulation-status.md b/docs/proposals/browser-emulation-status.md index 22787e009..71d73aeb7 100644 --- a/docs/proposals/browser-emulation-status.md +++ b/docs/proposals/browser-emulation-status.md @@ -71,3 +71,11 @@ | Anleitung | bereit | (dieser PR) | [human-tests.md](human-tests.md): P0.3 mit `npm run spike:p03` (Messseite für Browser auf Geräten), P1.14-Aufnahmen mit `npm run record:hardware`, P7.1 Compile-Kapazität auf dem Referenz-Host | Status: offen · in Arbeit · erledigt · wartet auf Mensch · blockiert + +### Nachtrag: Verfügbarkeit der Beispiele + +GitHub-Ref-Auflösung nutzt die kleine SHA-Repräsentation mit eigenem 256-Byte-Limit; +große Commit-Diffs überschreiten nicht mehr das Manifestbudget. Der lokale +Katalog `/api/examples/builtin/catalog` lädt unabhängig vom externen Katalog. +Das Menü hat getrennte Lade-/Fehlerzustände und Retry; Regressionstests decken +Ausfälle, langsame Antworten, Quellwechsel und Wiederherstellung ab. diff --git a/e2e/examples-availability.spec.ts b/e2e/examples-availability.spec.ts new file mode 100644 index 000000000..920e0a9b3 --- /dev/null +++ b/e2e/examples-availability.spec.ts @@ -0,0 +1,58 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const catalogPath = /\/api\/examples(?:\?.*)?$/; +const externalCatalog = { + schemaVersion: 1, + source: { selection: "default", mode: "repository-ref", repository: "owner/repo", ref: "main", revision: "a".repeat(40), status: "remote", stale: false }, + examples: [{ id: "remote-test", title: "Remote example", category: "Basics", main: "remote.ino", source: "external", files: [{ name: "remote.ino", path: "remote.ino" }] }], +}; + +async function openExamples(page: Page) { + await page.goto("/"); + await page.waitForFunction(() => Boolean((globalThis as unknown as Record).__MONACO_EDITOR__)); + await expect(page.getByRole("button", { name: "Examples", exact: true })).toBeEnabled(); + await page.getByRole("button", { name: "Examples", exact: true }).click(); +} + +const editorValue = (page: Page) => page.evaluate(() => ( + (globalThis as unknown as Record).__MONACO_EDITOR__ as { getValue(): string } +).getValue()); + +test.describe("independent built-in examples", () => { + test("loads real Blink files while the external catalog never completes", async ({ page }) => { + let held: Route | undefined; + await page.route(catalogPath, route => { held = route; }); + await openExamples(page); + await expect(page.getByText("Loading external examples...")).toBeVisible(); + await page.getByRole("button", { name: "Built-in", exact: true }).click(); + const detail = page.waitForResponse(response => response.url().endsWith("/api/examples/builtin-01-blink-ino")); + await page.getByRole("button", { name: /01-blink\.ino/ }).click(); + expect((await detail).status()).toBe(200); + await expect.poll(() => editorValue(page)).toContain("const int ledPin = LED_BUILTIN"); + await held?.abort(); + }); + + test("keeps local examples through an external failure and restores only the external group on Retry", async ({ page }) => { + let attempts = 0; + let localCatalogRequests = 0; + page.on("request", request => { if (request.url().endsWith("/api/examples/builtin/catalog")) localCatalogRequests++; }); + await page.route(catalogPath, async route => { + attempts++; + await route.fulfill({ status: attempts === 1 ? 503 : 200, json: attempts === 1 ? { schemaVersion: 1, error: { code: "SOURCE_UNAVAILABLE" } } : externalCatalog }); + }); + await page.route(/\/api\/examples\/remote-test(?:\?.*)?$/, route => route.fulfill({ json: { files: [{ name: "remote.ino", content: "// Remote recovery\nvoid setup(){}\nvoid loop(){}" }] } })); + await openExamples(page); + await expect(page.getByText(/External examples are currently unavailable/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Built-in", exact: true })).toBeEnabled(); + const before = await editorValue(page); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(page.getByRole("button", { name: "External", exact: true })).toBeVisible(); + expect(localCatalogRequests).toBe(1); + expect(await editorValue(page)).toBe(before); + await expect(page.getByRole("button", { name: "Built-in", exact: true })).toHaveCount(1); + await page.getByRole("button", { name: "External", exact: true }).click(); + await page.getByRole("button", { name: "Basics", exact: true }).click(); + await page.getByRole("button", { name: /Remote example/ }).click(); + await expect.poll(() => editorValue(page)).toContain("// Remote recovery"); + }); +}); diff --git a/ssot/ssot_function_definition_ExternalExamples.md b/ssot/ssot_function_definition_ExternalExamples.md index 45431ed35..45a910829 100644 --- a/ssot/ssot_function_definition_ExternalExamples.md +++ b/ssot/ssot_function_definition_ExternalExamples.md @@ -184,6 +184,25 @@ sind Repository, Ref und Revision null, der Status ist `builtin`. `stale=true` bedeutet, dass ausschließlich der LKG derselben Repository-/Ref-Auswahl geliefert wird. +### 5.1 Unabhängige Verfügbarkeit im Beispiele-Menü + +Beim Öffnen MUSS der Client den eingebauten Katalog und den bisherigen +Katalog parallel anfragen und ihre Ergebnisse getrennt verarbeiten. Eingebaute +Einträge kommen ausschließlich aus `/api/examples/builtin/catalog`; aus dem +gemeinsamen Katalog übernimmt das Menü ausschließlich externe Einträge. +Ein erfolgreicher Teil bleibt während Ladezeit oder Fehler des anderen bedienbar. +Bei Fehler MUSS ein dauerhafter gruppenbezogener Hinweis mit `Retry` erscheinen. +Retry lädt nur die betroffene Gruppe; es gibt keine automatische Retry-Schleife. +`No examples available` gilt nur, wenn beide Gruppen erfolgreich leer sind. + +Katalogabrufe, Fehler und Retry dürfen weder Editorinhalt noch Browser-Auswahl +ändern. Überholte/abgebrochene Antworten dürfen keine neuere Auswahl überschreiben. +Der eingebaute Abruf hat einen eigenen AbortController und verändert weder den +globalen externen Katalogzustand noch dessen Tutor-/Revisionskontext. Eingebaute +Details setzen beim tatsächlichen Laden den externen Beispielkontext wie bisher +zurück. Ein Backend-Ausfall verwendet weiter das bestehende Offline-Verhalten; +es gibt keinen persistenten Browsercache für Beispielinhalte. + ## 6. Ref-Auflösung und atomare Aktivierung Für öffentliche GitHub-Repositories löst der Server den validierten Ref über diff --git a/tests/client/builtin-examples.test.ts b/tests/client/builtin-examples.test.ts new file mode 100644 index 000000000..4a38ddcf8 --- /dev/null +++ b/tests/client/builtin-examples.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getExternalExamplesState, loadBuiltInExamplesCatalog, normalizeSelection, setExternalExamplesOverride } from "../../client/src/lib/external-examples"; + +const catalog = { schemaVersion: 1, source: { selection: "default", mode: "builtin", repository: null, ref: null, revision: null, status: "builtin", stale: false }, examples: [{ id: "blink", source: "builtin" }] }; +afterEach(() => { vi.restoreAllMocks(); setExternalExamplesOverride(null); }); + +describe("built-in catalog client", () => { + it("loads only its endpoint and does not alter external state", async () => { + setExternalExamplesOverride(normalizeSelection("owner/repo", "main")); + const before = getExternalExamplesState(); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(catalog))); + const signal = new AbortController().signal; + await expect(loadBuiltInExamplesCatalog(signal)).resolves.toEqual(catalog); + expect(fetchMock).toHaveBeenCalledWith("/api/examples/builtin/catalog", { signal }); + expect(getExternalExamplesState()).toBe(before); + }); + + it.each([ + { ...catalog, source: { ...catalog.source, mode: "repository-ref" } }, + { ...catalog, source: { ...catalog.source, revision: "a".repeat(40) } }, + { ...catalog, examples: [{ id: "external", source: "external" }] }, + { ...catalog, examples: [null] }, + ])("rejects responses outside the built-in contract", async (payload) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(payload))); + await expect(loadBuiltInExamplesCatalog()).rejects.toThrow(); + }); +}); diff --git a/tests/client/examples-menu.availability.test.tsx b/tests/client/examples-menu.availability.test.tsx new file mode 100644 index 000000000..8a4f2cc69 --- /dev/null +++ b/tests/client/examples-menu.availability.test.tsx @@ -0,0 +1,89 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ExamplesMenu } from "../../client/src/components/features/examples-menu"; +import { normalizeSelection, setExternalExamplesOverride } from "../../client/src/lib/external-examples"; + +vi.mock("@/components/ui/dropdown-menu", async () => { + const React = await import("react"); + const Context = React.createContext({ open: false, setOpen: (_open: boolean) => undefined }); + return { + DropdownMenu: ({ open, onOpenChange, children }: any) => {children}, + DropdownMenuTrigger: ({ children }: any) => { const { open, setOpen } = React.useContext(Context); return React.cloneElement(children, { onClick: () => setOpen(!open) }); }, + DropdownMenuContent: ({ children, ...props }: any) => React.useContext(Context).open ?
{children}
: null, + }; +}); +vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast: vi.fn() }) })); + +const local = { id: "builtin-blink", title: "Blink", category: "Built-in", source: "builtin", main: "01-blink.ino", files: [{ name: "01-blink.ino", path: "01-blink.ino" }] }; +const remote = { id: "remote", title: "Remote example", category: "Basics", source: "external", main: "remote.ino", files: [{ name: "remote.ino", path: "remote.ino" }] }; +const source = { selection: "default", mode: "builtin", repository: null, ref: null, revision: null, status: "builtin", stale: false }; +const response = (examples: unknown[], external = false) => new Response(JSON.stringify({ schemaVersion: 1, source: external ? { ...source, mode: "repository-ref", repository: "owner/repo", ref: "main", revision: "a".repeat(40), status: "remote" } : source, examples })); +function deferred() { let resolve!: (value: T) => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } +const open = () => fireEvent.click(screen.getByRole("button", { name: "Examples" })); +afterEach(() => { vi.restoreAllMocks(); setExternalExamplesOverride(null); }); + +describe("independent examples menu availability", () => { + it("loads a local example while the external request is still pending", async () => { + const pending = deferred(); const onLoad = vi.fn(); + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith("/builtin/catalog")) return response([local]); + if (url.endsWith("/builtin-blink")) return new Response(JSON.stringify({ files: [{ name: "01-blink.ino", content: "blink code" }] })); + return pending.promise; + }); + render(); open(); + fireEvent.click(await screen.findByRole("button", { name: "Built-in" })); + fireEvent.click(screen.getByRole("button", { name: /01-blink.ino/ })); + await waitFor(() => expect(onLoad).toHaveBeenCalledWith([{ name: "01-blink.ino", content: "blink code" }], "01-blink.ino")); + await act(async () => pending.resolve(response([local, remote], true))); + }); + + it.each([503, 429])("keeps built-ins and retries only the failed external group after HTTP %s", async (status) => { + let failed = true; const onLoad = vi.fn(); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => String(input).endsWith("/builtin/catalog") ? response([local]) : failed ? new Response(JSON.stringify({ error: { code: status === 429 ? "RATE_LIMITED" : "SOURCE_UNAVAILABLE" } }), { status }) : response([local, remote], true)); + render(); open(); + expect(await screen.findByText(/External examples are currently unavailable/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Built-in" })).toBeEnabled(); + failed = false; fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByRole("button", { name: "External" })).toBeInTheDocument(); + expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/builtin/catalog"))).toHaveLength(1); + expect(screen.getAllByRole("button", { name: "Built-in" })).toHaveLength(1); + expect(onLoad).not.toHaveBeenCalled(); + }); + + it("keeps external examples usable when the built-in request fails", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async input => String(input).endsWith("/builtin/catalog") ? new Response("", { status: 500 }) : response([remote], true)); + render(); open(); + expect(await screen.findByRole("button", { name: "External" })).toBeEnabled(); + expect(screen.getByText(/Built-in examples are currently unavailable/)).toBeInTheDocument(); + expect(screen.queryByText("No examples available")).not.toBeInTheDocument(); + }); + + it("shows the empty state only after both groups successfully return empty", async () => { + const pending = deferred(); + vi.spyOn(globalThis, "fetch").mockImplementation(async input => String(input).endsWith("/builtin/catalog") ? response([]) : pending.promise); + render(); open(); + await waitFor(() => expect(screen.getByText(/Loading external examples/)).toBeInTheDocument()); + expect(screen.queryByText("No examples available")).not.toBeInTheDocument(); + await act(async () => pending.resolve(response([]))); + expect(await screen.findByText("No examples available")).toBeInTheDocument(); + }); + + it("ignores an old external response after the browser selection changes", async () => { + const old = deferred(); + vi.spyOn(globalThis, "fetch").mockImplementation(async input => { + const url = String(input); + if (url.endsWith("/builtin/catalog")) return response([local]); + return url.includes("repository=") ? response([{ ...remote, id: "new", title: "New selection" }], true) : old.promise; + }); + render(); open(); + await screen.findByRole("button", { name: "Built-in" }); + act(() => setExternalExamplesOverride(normalizeSelection("other/repo", "main"))); + fireEvent.click(await screen.findByRole("button", { name: "External" })); + fireEvent.click(screen.getByRole("button", { name: "Basics" })); + expect(screen.getByText("New selection")).toBeInTheDocument(); + await act(async () => old.resolve(response([remote], true))); + expect(screen.getByText("New selection")).toBeInTheDocument(); + expect(screen.queryByText("Remote example")).not.toBeInTheDocument(); + }); +}); diff --git a/tests/client/examples-menu.behavior.test.tsx b/tests/client/examples-menu.behavior.test.tsx index f3b5228da..0547dea42 100644 --- a/tests/client/examples-menu.behavior.test.tsx +++ b/tests/client/examples-menu.behavior.test.tsx @@ -28,6 +28,20 @@ vi.mock("@/components/ui/dropdown-menu", async () => { }; }); +// Route-aware fixtures preserve the original catalog/detail assertions with two independent requests. +function exampleResponses(catalog: Response, detail?: Response) { + return async (input: RequestInfo | URL): Promise => { + const url = String(input); + if (url.endsWith("/builtin/catalog")) { + const payload = await catalog.json(); + return { ok: true, json: async () => ({ ...payload, source: { selection: "default", mode: "builtin", repository: null, ref: null, revision: null, status: "builtin", stale: false }, examples: payload.examples.filter((example: { source: string }) => example.source === "builtin") }) } as Response; + } + if (url === "/api/examples" || url.startsWith("/api/examples?")) return catalog; + if (detail) return detail; + throw new Error("Unexpected example detail request"); + }; +} + const toast = vi.fn(); vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast }) })); @@ -39,8 +53,7 @@ afterEach(() => { describe("ExamplesMenu behavior", () => { it("loads, groups, expands, and selects examples", async () => { const fetchMock = vi.spyOn(globalThis, "fetch"); - fetchMock - .mockResolvedValueOnce({ + fetchMock.mockImplementation(exampleResponses({ ok: true, json: async () => ({ schemaVersion: 1, @@ -70,18 +83,17 @@ describe("ExamplesMenu behavior", () => { }, ], }), - } as Response) - .mockResolvedValueOnce({ + } as Response, { ok: true, json: async () => ({ files: [{ name: "01-blink.ino", content: "blink code" }], }), - } as Response); + } as Response)); const onLoadExample = vi.fn(); render(); fireEvent.click(screen.getByRole("button", { name: "Examples" })); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); await waitFor(() => expect(screen.getByText("Load Example")).toBeInTheDocument(), ); @@ -149,7 +161,7 @@ describe("ExamplesMenu behavior", () => { expect(fetchMock).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Examples" })); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); }); it("keeps built-in filenames aligned in compact non-wrapping rows", async () => { @@ -165,7 +177,7 @@ describe("ExamplesMenu behavior", () => { "09-nonblocking-timing.ino", "10-unosim-showcase.ino", ]; - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + vi.spyOn(globalThis, "fetch").mockImplementation(exampleResponses({ ok: true, json: async () => ({ schemaVersion: 1, @@ -186,7 +198,7 @@ describe("ExamplesMenu behavior", () => { files: [{ name, path: name }], })), }), - } as Response); + } as Response)); render(); fireEvent.click(screen.getByRole("button", { name: "Examples" })); @@ -213,23 +225,20 @@ describe("ExamplesMenu behavior", () => { }); }); - it("reports a failed examples request and ignores an individual failed file", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + it("keeps failed catalog requests visible with a retry action", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response("", { status: 500 }), ); render(); fireEvent.click(screen.getByRole("button", { name: "Examples" })); await waitFor(() => - expect(toast).toHaveBeenCalledWith( - expect.objectContaining({ title: "Failed to Load Examples" }), - ), + expect(screen.getByText(/Built-in examples are currently unavailable/)).toBeInTheDocument(), ); }); it("toggles with the platform shortcut and closes after loading an example", async () => { const fetchMock = vi.spyOn(globalThis, "fetch"); - fetchMock - .mockResolvedValueOnce({ + fetchMock.mockImplementation(exampleResponses({ ok: true, json: async () => ({ schemaVersion: 1, @@ -252,17 +261,16 @@ describe("ExamplesMenu behavior", () => { }, ], }), - } as Response) - .mockResolvedValueOnce({ + } as Response, { ok: true, json: async () => ({ files: [{ name: "blink.ino", content: "blink" }], }), - } as Response); + } as Response)); const onLoadExample = vi.fn(); render(); fireEvent.keyDown(document, { code: "KeyE", ctrlKey: true }); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); expect(screen.getByText("Load Example")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Built-in" })); fireEvent.click(screen.getByRole("button", { name: /blink\.ino/i })); @@ -278,7 +286,7 @@ describe("ExamplesMenu behavior", () => { }); it("separates built-in and external examples even when categories have the same name", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + vi.spyOn(globalThis, "fetch").mockImplementation(exampleResponses({ ok: true, json: async () => ({ schemaVersion: 1, @@ -308,7 +316,7 @@ describe("ExamplesMenu behavior", () => { }, ], }), - } as Response); + } as Response)); render(); fireEvent.click(screen.getByRole("button", { name: "Examples" })); @@ -346,8 +354,7 @@ describe("ExamplesMenu behavior", () => { it("loads an external multi-file example through the existing detail endpoint", async () => { const fetchMock = vi.spyOn(globalThis, "fetch"); - fetchMock - .mockResolvedValueOnce({ + fetchMock.mockImplementation(exampleResponses({ ok: true, json: async () => ({ schemaVersion: 1, @@ -376,8 +383,7 @@ describe("ExamplesMenu behavior", () => { }, ], }), - } as Response) - .mockResolvedValueOnce({ + } as Response, { ok: true, json: async () => ({ files: [ @@ -385,12 +391,12 @@ describe("ExamplesMenu behavior", () => { { name: "motor.h", content: "void setup();" }, ], }), - } as Response); + } as Response)); const onLoadExample = vi.fn(); render(); fireEvent.click(screen.getByRole("button", { name: "Examples" })); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); fireEvent.click(screen.getByRole("button", { name: "External" })); fireEvent.click(screen.getByRole("button", { name: "Motors" })); fireEvent.click(screen.getByRole("button", { name: /Motor Control/ })); From cdf2b77cde453cecd77a5b2118b372b57d9f472b Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 23 Oct 2026 23:19:58 +0200 Subject: [PATCH 4/7] fix(examples): settle superseded catalog loads for retry --- .../src/components/features/examples-menu.tsx | 6 +++-- .../examples-menu.availability.test.tsx | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/client/src/components/features/examples-menu.tsx b/client/src/components/features/examples-menu.tsx index 3ea6c129e..c5b943971 100644 --- a/client/src/components/features/examples-menu.tsx +++ b/client/src/components/features/examples-menu.tsx @@ -93,8 +93,10 @@ export function ExamplesMenu({ } : {}), })); setGroups(current => ({ ...current, [source]: { items, loading: false, error: false, loaded: true } })); - } catch (error) { - if (!isCurrent() || (error instanceof DOMException && error.name === "AbortError")) return; + } catch { + // Our own cancellation is handled by isCurrent; another shared-catalog consumer + // may cancel its transport while this menu is still open, which needs Retry. + if (!isCurrent()) return; setGroups(current => ({ ...current, [source]: { ...emptyGroup(), error: true } })); } }, []); diff --git a/tests/client/examples-menu.availability.test.tsx b/tests/client/examples-menu.availability.test.tsx index 8a4f2cc69..3da3e2ad2 100644 --- a/tests/client/examples-menu.availability.test.tsx +++ b/tests/client/examples-menu.availability.test.tsx @@ -23,6 +23,32 @@ const open = () => fireEvent.click(screen.getByRole("button", { name: "Examples" afterEach(() => { vi.restoreAllMocks(); setExternalExamplesOverride(null); }); describe("independent examples menu availability", () => { + it("settles a superseded shared request so the external group can be retried", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async input => { + if (String(input).endsWith("/builtin/catalog")) return response([local]); + throw new DOMException("Superseded by another external catalog consumer", "AbortError"); + }); + render(); open(); + expect(await screen.findByRole("button", { name: "Retry" })).toBeInTheDocument(); + expect(screen.queryByText("Loading external examples...")).not.toBeInTheDocument(); + }); + + it("aborts both requests on close and ignores their late failures", async () => { + const external = deferred(); + const signals: AbortSignal[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + signals.push(init!.signal!); + return String(input).endsWith("/builtin/catalog") ? response([local]) : external.promise; + }); + render(); open(); + await screen.findByRole("button", { name: "Built-in" }); + open(); + expect(signals).toHaveLength(2); + expect(signals.every(signal => signal.aborted)).toBe(true); + await act(async () => external.resolve(new Response("", { status: 503 }))); + expect(screen.queryByText(/currently unavailable/)).not.toBeInTheDocument(); + }); + it("loads a local example while the external request is still pending", async () => { const pending = deferred(); const onLoad = vi.fn(); vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { From e5552ef5820b533e7ad49bb6d4b47188c1760bec Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 23 Oct 2026 23:25:33 +0200 Subject: [PATCH 5/7] fix(examples): guard late details and diagnose invalid revisions --- .../src/components/features/examples-menu.tsx | 7 ++++++- .../examples/github-revision-resolver.ts | 1 + ...ot_function_definition_ExternalExamples.md | 2 ++ .../examples-menu.availability.test.tsx | 21 ++++++++++++++++++- .../github-compact-resolution.test.ts | 11 ++++++++++ 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/client/src/components/features/examples-menu.tsx b/client/src/components/features/examples-menu.tsx index c5b943971..f940fd621 100644 --- a/client/src/components/features/examples-menu.tsx +++ b/client/src/components/features/examples-menu.tsx @@ -67,6 +67,8 @@ export function ExamplesMenu({ const focusedIndexRef = useRef(-1); const { toast } = useToast(); const { override } = useExternalExamples(); + const selectionRef = useRef(override); + selectionRef.current = override; const loadGeneration = useRef(0); const capabilitiesRef = useRef(capabilities); const controllers = useRef>>({}); @@ -254,11 +256,13 @@ export function ExamplesMenu({ }, [open]); const handleLoadExample = async (example: Example, boardConfirmed = false) => { + const selection = selectionRef.current; if (!capabilitiesRef.current.canUseServerExamples) return; if (loadingExampleId) return; // An example for another board asks first: switch the board, load anyway, or cancel (plan P4.6). if (!boardConfirmed && example.board && example.board !== getActiveBoard()) { const { boards } = await loadRuntimeConfig(); + if (selection !== selectionRef.current) return; const prompt = exampleBoardPrompt(example.board, boards.find((board) => board.id === getActiveBoard()), boards); if (prompt) { setBoardQuestion({ example, prompt }); @@ -279,7 +283,7 @@ export function ExamplesMenu({ ); if (!response.ok) throw new Error("Failed to fetch example"); const detail = (await response.json()) as ExampleDetail; - if (!capabilitiesRef.current.canUseServerExamples) return; + if (!capabilitiesRef.current.canUseServerExamples || selection !== selectionRef.current) return; if (!Array.isArray(detail.files) || detail.files.length === 0) { throw new Error("Example contains no files"); } @@ -295,6 +299,7 @@ export function ExamplesMenu({ setOpen(false); } catch (error) { + if (selection !== selectionRef.current) return; console.error(`Failed to load example ${example.id}:`, error); toast({ title: "Failed to Load Example", diff --git a/server/services/examples/github-revision-resolver.ts b/server/services/examples/github-revision-resolver.ts index 73f3f7c6a..bfd5b2f28 100644 --- a/server/services/examples/github-revision-resolver.ts +++ b/server/services/examples/github-revision-resolver.ts @@ -40,6 +40,7 @@ export class GitHubRevisionResolver implements RevisionResolver { } const parsed = fullCommitShaSchema.safeParse(text.trim()); if (!parsed.success) { + this.logger.warn(`resolve-ref request=${JSON.stringify(context.requestId)}: invalid-sha`); throw new ExamplesError("SOURCE_UNAVAILABLE", "External examples ref response is invalid"); } return parsed.data; diff --git a/ssot/ssot_function_definition_ExternalExamples.md b/ssot/ssot_function_definition_ExternalExamples.md index 45a910829..65bd15319 100644 --- a/ssot/ssot_function_definition_ExternalExamples.md +++ b/ssot/ssot_function_definition_ExternalExamples.md @@ -197,6 +197,8 @@ Retry lädt nur die betroffene Gruppe; es gibt keine automatische Retry-Schleife Katalogabrufe, Fehler und Retry dürfen weder Editorinhalt noch Browser-Auswahl ändern. Überholte/abgebrochene Antworten dürfen keine neuere Auswahl überschreiben. +Auch ein bereits gestarteter Detailabruf aus einer früheren Auswahl darf +nach einem Quellenwechsel weder Editor noch aktiven Tutor-Beispielkontext setzen. Der eingebaute Abruf hat einen eigenen AbortController und verändert weder den globalen externen Katalogzustand noch dessen Tutor-/Revisionskontext. Eingebaute Details setzen beim tatsächlichen Laden den externen Beispielkontext wie bisher diff --git a/tests/client/examples-menu.availability.test.tsx b/tests/client/examples-menu.availability.test.tsx index 3da3e2ad2..e88c12029 100644 --- a/tests/client/examples-menu.availability.test.tsx +++ b/tests/client/examples-menu.availability.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ExamplesMenu } from "../../client/src/components/features/examples-menu"; -import { normalizeSelection, setExternalExamplesOverride } from "../../client/src/lib/external-examples"; +import { getExternalExamplesState, normalizeSelection, setExternalExamplesOverride } from "../../client/src/lib/external-examples"; vi.mock("@/components/ui/dropdown-menu", async () => { const React = await import("react"); @@ -23,6 +23,25 @@ const open = () => fireEvent.click(screen.getByRole("button", { name: "Examples" afterEach(() => { vi.restoreAllMocks(); setExternalExamplesOverride(null); }); describe("independent examples menu availability", () => { + it("does not write editor or Tutor context from a detail requested before a source change", async () => { + const detail = deferred(); const onLoad = vi.fn(); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async input => { + const url = String(input); + if (url.endsWith("/builtin/catalog")) return response([local]); + if (url.includes("/api/examples/remote?")) return detail.promise; + return response([remote], true); + }); + render(); open(); + fireEvent.click(await screen.findByRole("button", { name: "External" })); + fireEvent.click(screen.getByRole("button", { name: "Basics" })); + fireEvent.click(screen.getByRole("button", { name: /Remote example/ })); + await waitFor(() => expect(fetchMock.mock.calls.some(([url]) => String(url).includes("/api/examples/remote?"))).toBe(true)); + act(() => setExternalExamplesOverride(normalizeSelection("other/repo", "main"))); + await act(async () => detail.resolve(new Response(JSON.stringify({ files: [{ name: "remote.ino", content: "old selection" }] })))); + expect(onLoad).not.toHaveBeenCalled(); + expect(getExternalExamplesState().activeCourseContent).toBeNull(); + }); + it("settles a superseded shared request so the external group can be retried", async () => { vi.spyOn(globalThis, "fetch").mockImplementation(async input => { if (String(input).endsWith("/builtin/catalog")) return response([local]); diff --git a/tests/server/examples/github-compact-resolution.test.ts b/tests/server/examples/github-compact-resolution.test.ts index add89a587..5f01b2785 100644 --- a/tests/server/examples/github-compact-resolution.test.ts +++ b/tests/server/examples/github-compact-resolution.test.ts @@ -6,6 +6,17 @@ const sha = "a".repeat(40); const context = { identity: "user", requestId: "diagnostic-request" }; describe("compact GitHub revision resolution", () => { + it("logs invalid SHA responses without leaking their contents", async () => { + const warn = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); + try { + const fetchText = vi.fn(async () => "private-upstream-body"); + await expect(new GitHubRevisionResolver({ fetchText }).resolve("owner/repo", "main", context)).rejects.toMatchObject({ code: "SOURCE_UNAVAILABLE" }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("invalid-sha")); + expect(warn.mock.calls.flat().join(" ")).toContain(context.requestId); + expect(warn.mock.calls.flat().join(" ")).not.toContain("private-upstream-body"); + } finally { warn.mockRestore(); } + }); + it.each(["main", "v1.2.3", sha])("requests a bounded SHA representation for %s", async (ref) => { const fetchText = vi.fn(async () => `${sha}\n`); await expect(new GitHubRevisionResolver({ fetchText }).resolve("owner/repo", ref, context)).resolves.toBe(sha); From 6276532a768bbf6dc41b66149cb12a5643722bcd Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 23 Oct 2026 23:29:16 +0200 Subject: [PATCH 6/7] docs(examples): record implementation decisions and verification --- .../plans/2026-10-23-example-loading.md | 165 ++++++++++++++++++ .../2026-10-23-example-loading-design.md | 88 ++++++++++ .../http-provider-representation.test.ts | 6 +- 3 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-23-example-loading.md create mode 100644 docs/superpowers/specs/2026-10-23-example-loading-design.md diff --git a/docs/superpowers/plans/2026-10-23-example-loading.md b/docs/superpowers/plans/2026-10-23-example-loading.md new file mode 100644 index 000000000..dea5648a6 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-example-loading.md @@ -0,0 +1,165 @@ +# Reliable Example Loading Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Status: Umsetzung vom Nutzer beauftragt, inline auf isoliertem Branch ausgeführt; diese Checkliste dokumentiert die Abnahme. + +**Goal:** Kleine Commit-Auflösung und unabhängig verfügbare eingebaute Beispiele bei unveränderten externen Auswahl-, Revisions- und Sicherheitsverträgen. + +**Architecture:** PR 1 fordert eine begrenzte SHA-Repräsentation von GitHub an. PR 2 ergänzt einen lokalen Built-in-Katalog und lädt im Menü lokale und externe Gruppen parallel mit getrennten Ergebnissen. Der bisherige gemeinsame Katalog bleibt für bestehende Aufrufer erhalten. + +**Tech Stack:** TypeScript, Node.js-Fetch, Express, React, Zod, Vitest, Playwright; keine neue Abhängigkeit. + +**Spec:** [Vorschlag und Abnahmekriterien](../specs/2026-10-23-example-loading-design.md). + +## Global Constraints + +- Der Nutzer hat den Vorschlag und die vollständige Umsetzung einschließlich der beschriebenen Test-Anpassungen beauftragt. +- Ein gemeinsamer PR gegen `feature/browser-emulation` mit getrennten Implementierungscommits; für die Umsetzung eigener Working-Branch und isolierter Checkout. Offene ATtiny85-Arbeiten werden nicht übernommen. +- SHA-Antwortlimit: **256 Bytes**. Nach `trim()` exakt `^[0-9a-f]{40}$`. Manifest-Default bleibt **262.144 Bytes**. +- Neue Route ausschließlich `GET /api/examples/builtin/catalog`; Antwort wiederverwendet `ExamplesCatalogResponse`, Schema-Version **1**. +- Bestehende Routen, Fehlercodes, Auswahlhierarchie, Quota-/LKG-Regeln und Detailbindung an Repository/Revision bleiben unverändert. +- Keine neuen Dependencies, keine Änderungen am Example-Repo und kein persistenter Browser-Contentcache. +- Bestehende Tests sind gemäß `ssot/ssot_agent_policy.md` Spezifikationen. Die unten genannten Erwartungs- und Fixture-Anpassungen benötigen vor ihrer Ausführung die dort geforderte ausdrückliche Zustimmung; neue Tests benötigen keine Umdeutung bestehender Erwartungen. +- Vor einem Push laufen Unit-, Integrations- und E2E-Suite vollständig; Commit- und Pre-Push-Hooks bleiben aktiv. + +## Review Focus + +- Ein sehr großer Commit muss dieselbe kleine SHA-Antwort nutzen; JSON darf nicht als Ausweichweg das Größenlimit umgehen. Tests in Task 1. +- Ein hängender externer Request darf den ersten sichtbaren und ladbaren lokalen Eintrag nicht verzögern. Tests in Tasks 2 und 3. +- Ein späteres Ergebnis einer alten Browser-Auswahl darf weder neue Einträge noch Tutor-Kontext überschreiben. Tests in Task 3. +- Ein GitHub-Token und der SHA-Accept-Header dürfen nicht auf Raw-Content-Hosts gelangen; Rate Limits und Abbrüche bleiben wirksam. Tests in Task 1. +- Ein externer Ausfall darf weder als erfolgreicher externer Snapshot noch als Editorwechsel erscheinen. Tests in Tasks 2 und 3. + +## Vorbereitungen für die spätere Umsetzung + +- [x] Basisbranch und vorhandene Änderungen prüfen; einen isolierten Checkout auf dem Working-Branch verwenden. +- [x] Den Vorschlag und die begründeten Test-Anpassungen prüfen lassen. Keine bestehenden Tests vor dieser Zustimmung ändern. +- [x] Ausgangsstand mit TypeScript und den betroffenen vorhandenen Tests prüfen. Vorbestehende Fehler gesondert erfassen. + +## Task 1: Kompakte Commit-Auflösung mit Regressionstests und Vertragsdokumentation + +**Lieferabschnitt:** 1 auf `fix/examples-loading`. + +**Files:** +- Modify: `server/services/examples/http-provider.ts` +- Modify: `server/services/examples/github-revision-resolver.ts` +- Add: `tests/server/examples/github-compact-resolution.test.ts` +- Add: `tests/server/examples/http-provider-representation.test.ts` +- Modify, nach Zustimmung: `tests/server/examples/github-revision-resolver.test.ts` +- Modify: `ssot/ssot_function_definition_ExternalExamples.md`, `docs/SECURITY.md`, `docs/INSTALL_LOCAL.md`, `docs/INSTALL_SERVER.md` + +**Interfaces:** +- Extend: `TextFetchOptions { readonly representation?: "github-commit-sha" }` in `http-provider.ts`. +- Extend: `TextFetcher.fetchText(url: URL, maxBytes: number, signal?: AbortSignal, options?: TextFetchOptions): Promise`; bisherige Drei-Argument-Aufrufer bleiben gültig. +- Preserve: `GitHubRevisionResolver.resolve(repository, ref, context): Promise`. +- Add: `ExamplesResponseTooLargeError extends Error` in `http-provider.ts`; beide Größenprüfungen verwenden denselben Typ und behalten einen sicheren, festen Meldungstext. + +- [x] **Vertrag ergänzen:** SSOT Abschnitt Ref-Auflösung auf SHA-Repräsentation, eigenständiges 256-Byte-Limit und unveränderte Ref-Arten präzisieren. Die Browser-API bleibt in diesem PR unverändert. +- [x] **Fehlschlagende Tests ergänzen:** `requests the SHA representation for branch, tag and full SHA`; erwarteter Accept-Header ist `application/vnd.github.sha`, Budget 256 Bytes, Rückgabe der validierte SHA. `rejects malformed, JSON and overlong SHA responses`; gültig sind 40 kleine Hex-Zeichen, optional mit abschließendem Whitespace innerhalb des Limits. `honors an aborted context and GitHub rate limits`. +- [x] **Fetcher-Regression ergänzen:** Header nur auf kontrollierten Commit-URLs von `api.github.com`; Ablehnung der Repräsentation auf Raw-/anderen URLs; kein Token auf Raw-URLs; kein JSON-Fallback. Deklariertes und gestreamtes Oversize lösen die neue Fehlerklasse aus, auch wenn kein `Content-Length` vorliegt. Eine kompakte SHA-Antwort wird unabhängig von der Größe der alternativen JSON-Repräsentation verarbeitet. +- [x] **Rot prüfen:** `npx vitest run --project=unit-node tests/server/examples/github-compact-resolution.test.ts tests/server/examples/http-provider-representation.test.ts` muss an der fehlenden Repräsentation scheitern. +- [x] **Fetcher und Resolver implementieren:** Optionen über die vorhandene Fetch-Kette weiterreichen; URL/Host vor Versand prüfen; feste Accept-Auswahl mit optionaler bestehender Authentifizierung kombinieren. Der Resolver verwendet das 256-Byte-Limit und `fullCommitShaSchema.safeParse(text.trim())` statt JSON-Parsing. +- [x] **Sichere Diagnose ergänzen:** Resolver nutzt vorhandenen Logger und `safeErrorLogMetadata`; protokollierte Felder: Phase `resolve-ref`, Request-ID, sicherer Fehlertyp/-code. Keine Rohantwort, URL mit Zugangsdaten oder vollständige externe Fehlernachricht. Öffentliche `SOURCE_UNAVAILABLE`-/`RATE_LIMITED`-Zuordnung bleibt bestehen. +- [x] **Notwendige bestehende Test-Anpassung:** Im bisherigen Resolver-Test wird die erfolgreiche JSON-Antwort `{ sha }` durch SHA-Text ersetzt und der erwartete Fetch-Aufruf um die Repräsentation ergänzt. Begründung: Der ausgehende Transportvertrag ändert sich absichtlich, die erwartete aufgelöste Revision und Fehlercodes bleiben gleich. Der Test für einen ungültigen SHA prüft weiterhin denselben Fehler. +- [x] **Grün prüfen:** `npx vitest run --project=unit-node tests/server/examples` und `npm run check`. Die bisherigen Header-/Redirect-Tests für gewöhnliche Textabrufe bleiben unverändert gültig. +- [x] **Betriebsdokumentation abschließen:** SECURITY beschreibt dieselben Grenzen mit der neuen Repräsentation. INSTALL_LOCAL und INSTALL_SERVER erklären die Korrektur und die Rückkehr vom temporären Manifestlimit zu 262.144 Bytes. Das Entfernen eines konkret gesetzten Overrides erfolgt nur, wenn er ausschließlich wegen dieses Fehlers gesetzt wurde. +- [x] **Reale Abnahme vorbereiten:** Mit unveränderten Defaults den betroffenen Ref auflösen; HTTP 200, validierter 40-Zeichen-SHA und kleiner Body. Den gemeinsamen Katalog mit den realen Beispielen laden; keine feste Live-Inhaltszahl in Unit-Tests verwenden. +- [x] **Review und Commit:** Nur PR-1-Dateien vormerken. Vorschlag: `fix(examples): resolve GitHub refs using compact SHA responses`. + +**Ergebnis:** Der aktuelle Fehler ist mit dem bisherigen API-Vertrag behoben. Eingebaute Beispiele sind bis PR 2 weiterhin von der Verfügbarkeit des gemeinsamen Katalogs abhängig. + +## Task 2: Unabhängigen Built-in-Katalog bereitstellen und API dokumentieren + +**Lieferabschnitt:** 2 auf demselben Branch, nach dem abgeschlossenen Transport-Commit. + +**Files:** +- Modify: `server/services/examples/examples-repository.ts` +- Modify: `server/routes/examples.routes.ts` +- Add: `tests/server/examples/builtin-catalog.test.ts` +- Modify, nach Zustimmung: `tests/server/routes/examples.routes.test.ts` +- Modify: `ssot/ssot_function_definition_ExternalExamples.md`, `docs/adr/0005-browser-scoped-external-examples.md`, `docs/ARCHITECTURE.md`, `docs/SECURITY.md` + +**Interfaces:** +- Add: `ExamplesRepository.getBuiltInCatalog(): Promise`. +- Extend: Repository-Port von `registerExamplesRoutes` um `getBuiltInCatalog`. +- Add route: `GET /api/examples/builtin/catalog`, bestehende Built-in-Metadaten, keine Query-Parameter. Keine Änderung an `ExamplesCatalogResponse` oder `ExamplesSourceMetadata`. + +- [x] **Vertrag zuerst ergänzen:** Neuer lokaler Katalog ist unabhängig von der externen Source. Externe Fehler bleiben auf `/api/examples` kontrollierte Fehler; gleiche-Source-LKG und transaktionale Validate-Regeln bleiben normativ erhalten. Kein neuer externer Fallback. +- [x] **Fehlschlagenden Repository-Test ergänzen:** Lokale Fixture mit zwei Beispielen, Source-Provider wirft oder bleibt ungelöst. `getBuiltInCatalog()` liefert trotzdem beide Beispiele und Built-in-Metadaten; `resolve` und `getRevision` werden nie aufgerufen. Keine Quota-/Load-Aktion. +- [x] **Fehlschlagende HTTP-Tests ergänzen:** Endpoint liefert HTTP 200 mit Built-in-Vertrag auch bei externer Störung. Anonymer Gateway-Default-Read bleibt erlaubt; Query-Parameter werden als `INVALID_SELECTION` abgelehnt. `/api/examples/:id` funktioniert unverändert. +- [x] **Notwendige Fixture-Anpassung:** Die vorhandene Route-Test-Harness erhält genau den neuen Repository-Port `getBuiltInCatalog`. Bisherige Routen-, Auth-, Quota- und Fehlerassertionen werden nicht abgeschwächt. Begründung: Der registrierte Repository-Port wurde um eine eigenständige lokale Operation erweitert. +- [x] **Rot prüfen:** `npx vitest run --project=unit-node tests/server/examples/builtin-catalog.test.ts` und `npx vitest run --project=unit-node-http tests/server/routes/examples.routes.test.ts` müssen an fehlendem Port/Endpoint scheitern. +- [x] **Implementieren:** Gemeinsamen BuiltInProvider und den bisherigen Katalog-Mapper verwenden. Bestehender Built-in-only-Zweig kann die neue Methode wiederverwenden. Route vor dem dynamischen Detailhandler registrieren; keine externe Konfiguration oder Browser-Präferenz ändern. +- [x] **Grün prüfen:** Die beiden gezielten Testbefehle und die bestehende Source-Provider-/Cache-Testgruppe bestehen. Bestehende Fehler auf dem gemeinsamen Katalog bleiben identisch. +- [x] **Dokumentation mitliefern:** SSOT erhält Request/Response-Beispiel und Fehlerverhalten. ADR 0005 erhält datierten Nachtrag; ARCHITECTURE zeigt die zwei parallelen Katalogwege. SECURITY dokumentiert Berechtigung und ausschließlich lokale Datenquelle. +- [x] **Commit:** `feat(examples): expose a source-independent built-in catalog`. + +**Ergebnis:** Der Server bietet die unabhängige lokale Operation an; der vorhandene Client kann bis Task 3 unverändert weiterarbeiten. + +## Task 3: Menü getrennt laden, Ausfälle sichtbar machen und Bedienung prüfen + +**Files:** +- Modify: `client/src/lib/external-examples.ts` +- Modify: `client/src/components/features/examples-menu.tsx` +- Add: `tests/client/builtin-examples.test.ts` +- Add: `tests/client/examples-menu.availability.test.tsx` +- Modify, nach Zustimmung: `tests/client/examples-menu.behavior.test.tsx` +- Add: `e2e/examples-availability.spec.ts` +- Modify: `docs/USER_GUIDE.md`, `docs/INSTALL_LOCAL.md`, `docs/INSTALL_SERVER.md`, `docs/TESTING_STANDARDS.md`, `docs/proposals/browser-emulation-status.md` + +**Interfaces:** +- Add: `loadBuiltInExamplesCatalog(signal?: AbortSignal): Promise` in `external-examples.ts`. Der vorhandene Validator wird wiederverwendet und zusätzlich auf ausschließlich Built-in-Quelle/-Einträge geprüft. Kein Schreiben in externen globalen State oder dessen Generation/Controller. +- Preserve: `refreshExternalExamplesCatalog`, externe Override-Persistenz und Detail-URLs. +- Menu state: je Gruppe Einträge, Ladezustand und Fehler; externe Einträge ausschließlich aus externem Erfolgskatalog. Eine erfolgreiche Gruppe bleibt unabhängig bedienbar. + +- [x] **Helper-Test ergänzen und rot prüfen:** `loadBuiltInExamplesCatalog` ruft nur die neue URL auf, reicht das AbortSignal weiter, lehnt falschen Source-/Payload-Vertrag ab und verändert weder Override noch globalen externen Katalog. `npx vitest run --project=unit-client tests/client/builtin-examples.test.ts`. +- [x] **Menütests vor Implementierung ergänzen:** Lokale Antwort wird aufgelöst, externe Antwort bleibt pending; lokale Einträge sind bereits bedienbar. Externes HTTP 503/429 lässt sie bestehen und zeigt Status plus Retry. Fehlende lokale Antwort lässt erfolgreiche externe Einträge bedienbar. Nur zwei erfolgreich leere Gruppen ergeben `No examples available`. +- [x] **Wiederherstellung und Rennen prüfen:** Retry ersetzt nur seine Gruppe; kein Editor-/Präferenzwechsel. Ein Quellwechsel während eines ausstehenden externen Requests unterdrückt dessen verspätetes Ergebnis. Schließen/Abbruch zeigt keinen falschen Fehler. Kombinierter Erfolg enthält lokale und externe IDs jeweils einmal. +- [x] **Rot prüfen:** `npx vitest run --project=unit-client tests/client/examples-menu.availability.test.tsx`. +- [x] **Implementieren:** Beide Abrufe beim Öffnen parallel starten; Ergebnisse nicht mit einem gemeinsam fehlschlagenden `Promise.all` koppeln. Ergebnisse quellbezogen verarbeiten und externe Metadaten weiterhin aus dem externen Katalog beziehen. Eigenen Built-in-Abbruchcontroller verwenden; externe Generation/Abort-Regeln beibehalten. +- [x] **UI-Texte und Retry implementieren:** Dauerhafte, zugängliche Statusmeldung pro ausgefallener Gruppe und `Retry`. Externe Meldung gemäß Design; lokale Meldung benennt ausschließlich die lokale Gruppe. Keine automatische Retry-Schleife und kein zusätzlicher Toast bei jedem Öffnen für denselben Zustand. +- [x] **Bestehende Menütests begründet anpassen:** Fetch-Fixtures auf URL-basierte Antworten für zwei Katalogabrufe umstellen. Die bisherige Erwartung eines Katalogrequests wird durch zwei gezielte URL-Erwartungen ersetzt. Tastatur-, Board-Rückfrage-, Detail- und `onLoadExample`-Assertions bleiben erhalten. Diese Änderungen prüfen den beabsichtigten neuen Ladeablauf. +- [x] **Grün prüfen:** `npx vitest run --project=unit-client tests/client/builtin-examples.test.ts tests/client/examples-menu.availability.test.tsx tests/client/examples-menu.behavior.test.tsx tests/client/external-examples.test.tsx`. +- [x] **Deterministische E2E-Szenarien ergänzen:** Nur den gemeinsamen Katalog im Browser kontrolliert verzögern oder mit HTTP 503 beantworten. Neuen lokalen Endpoint und `builtin-01-blink-ino` wirklich vom Testbackend laden. `Built-in` öffnen, Blink wählen, erwarteten Code im Monaco-Editor prüfen. Zweites Szenario: externer Retry mit synthetischem gültigen Katalog; externe Einträge erscheinen ohne Duplikate, Editor bleibt bis zur Auswahl gleich. Kein echter GitHub-Ausfall und keine neuen visuellen Baselines erforderlich. +- [x] **E2E prüfen:** Devserver vorher beenden; `npm run build` und `npm run test:e2e -- e2e/examples-availability.spec.ts`. Erwartet: alle Szenarien bestanden, keine externen Live-Requests als Testvoraussetzung. +- [x] **Nutzerdokumentation abschließen:** USER_GUIDE erklärt getrennte Ladezustände und Retry; eingebauter Detailabruf braucht weiterhin den UnoSim-Server. INSTALL_LOCAL/INSTALL_SERVER beschreiben SHA-Fix, neuen Diagnoseendpoint und verbleibende Quell-/Netzwerkfehler. IDE-Workspace-Ordner sind keine Runtime-Quellumschaltung. TESTING_STANDARDS dokumentiert deterministische Ausfallsimulation. Browser-Emulationsstatus vermerkt den abgeschlossenen Nachtrag erst nach Abnahme. +- [x] **Commit:** `fix(examples): keep built-in examples usable during external failures`. + +**Ergebnis:** Die beiden unabhängigen Datenwege sind im Menü nutzbar; vorhandene Settings-/Tutor-Verträge und Editoraktionen bleiben erhalten. + +## Abschließende Prüfung und Übergabe + +- [x] Spec und Code gegen alle sechs Abnahmekriterien prüfen, besonders LKG nur derselben Source und neue URL ohne Revisions-/Override-Kontext. +- [x] `npm run check`, `npm run lint`, `npm run check:docs`, `npm run build`. +- [x] Vollständige Läufe: `npm run test:unit`, `npm run test:integration`, `npm run test:e2e`. Vorgeschriebene Commit-/Pre-Push-Hooks einschließlich konfiguriertem Sonar-Gate bestehen. Breitere Docker-Prüfungen nur bei betroffenen Deployment-Verträgen oder entsprechenden CI-Gates. +- [x] Reale Browser-Abnahme im Development-Modus, mit Standard-Manifestlimit: lokale und externe Beispiele ladbar; dann kontrollierter externer Ausfall, lokale Beispiele weiterhin sichtbar und ladbar. Temporäre Diagnosekonfiguration danach entfernen. Keine Änderungen an einem fremden laufenden Devserver ohne entsprechenden Auftrag. +- [x] Dokumentationslinks und Aussagen mit der tatsächlichen Implementierung abgleichen. Kein vorzeitiges Umschreiben normativer Quellen während der Planungsphase. +- [x] PR-Beschreibungen nennen Ursache, neue Verfügbarkeit, Tests und etwaige begründete Test-Anpassungen. Kein Force-Push und kein Überspringen von Hooks. +- [ ] Nach Integration erledigte Entwurfs-/Plan-Dateien entsprechend `docs/README.md` entfernen; fachliche und betriebliche Dokumentation bleibt bestehen. + +## Plan-Selbstprüfung + +- Ursache und Transportgrenzen: Task 1. +- Unabhängiger API-Pfad und bestehende Source-Semantik: Task 2. +- Sichtbare Verfügbarkeit, Bedienung, Rennen und Tutor-Kontext: Task 3. +- Dokumentation ist jeder auslieferbaren Änderung zugeordnet, keine nachgelagerte Restaufgabe. +- Bestehende Tests und konkret notwendige Erwartungsanpassungen sind getrennt benannt; keine pauschale Erlaubnis zum Ändern von Spezifikationen. +- Inline-Ausführung mit unabhängiger abschließender Gesamtprüfung; Review-Korrekturen sind durch zusätzliche Regressionstests abgesichert. + +## Ausführungsentscheidungen und Review + +- Die zwei Lieferstufen wurden zu einem PR mit getrennten Commits zusammengefasst, um die vollständig beauftragte Umsetzung ohne Zwischen-Merge zu liefern. Bei gewünschter separater Abnahme können die Commits weiterhin getrennt betrachtet werden. +- Browserprüfungen laufen isoliert auf Port 3100. Nur temporäre, ignorierte Kopien der zwei alten Specs mit festem Port und die Testkonfiguration werden dafür übersetzt; die versionierten Tests bleiben unverändert. Die CI kann regulär Port 3000 verwenden. +- Zwei zusätzliche erfolgreiche Transport-Fixtures in `github-api-budget.test.ts` verwenden ebenfalls SHA-Text statt Commit-JSON. Alle bestehenden Quota-Assertions bleiben erhalten. +- Der im Review benannte bereits bestehende Detail-/Quellenwechsel-Race wurde wegen möglichem Überschreiben von Editor/Tutor mit behoben. Eine alte Auswahl wird verworfen, sobald die Quelle gewechselt wurde. +- Der Diagnosehinweis aus dem Review wurde als ausdrücklich geplante Funktion vollständig umgesetzt: Auch fehlerhafter SHA-Text erhält einen sicheren Logeintrag. +- Unabhängiges Code-Review und ein anschließender Red-Green-Fixdurchlauf sind erfolgt; es wurden keine Befunde zurückgestellt. + +## Verifizierter Abschluss + +- Node.js 24.20.0; TypeScript, ESLint, Dokumentationsprüfung und Build bestanden. +- 3.594 App-Unit-Tests und 635 Emulator-Unit-Tests bestanden; ein vorhandener Test übersprungen. +- 126 Toolchain-Integrationstests bestanden; ein vorhandener schwerer Test übersprungen. +- 53 Browser-Tests bestanden; isolierter Lauf auf Port 3100. +- Initialer JavaScript-Ladepfad: 690.529 Bytes bei unverändertem Limit 710.000 Bytes. +- Reale Quelle mit Standardlimit: zehn eingebaute und 51 externe Beispiele, Tutor-Daten gültig. +- Beide abschließenden Review-Korrekturen durch Red-Green-Regressionstests und erneute vollständige Läufe geprüft. diff --git a/docs/superpowers/specs/2026-10-23-example-loading-design.md b/docs/superpowers/specs/2026-10-23-example-loading-design.md new file mode 100644 index 000000000..0878f6602 --- /dev/null +++ b/docs/superpowers/specs/2026-10-23-example-loading-design.md @@ -0,0 +1,88 @@ +# Vorschlag: Zuverlässiges Laden von Beispielen + +Status: Angenommen und auf `fix/examples-loading` implementiert; normative Details stehen in der External-Examples-SSOT. +Stand: 2026-10-23. Lieferung: ein PR gegen `feature/browser-emulation`, mit getrennten Commits für Transport, API und Menü. + +## Problem und nachgewiesene Ursache + +Der laufende Entwicklungsserver liefert für `GET /api/examples` HTTP 503 mit `SOURCE_UNAVAILABLE`. Die zehn Dateien unter `public/examples` sind lesbar; ein direkter Detailabruf für `builtin-01-blink-ino` liefert HTTP 200. + +Die Commit-Antwort für `ttbombadil/unosim-examples`, Ref `main`, Revision `bdd4c0a5566bf9d12042d888de4e535555857d14`, war 273.115 Bytes groß. `GitHubRevisionResolver` verwendet dafür das Manifestlimit von 262.144 Bytes. Das eigentliche Manifest hat nur 16.935 Bytes. Mit einem testweise auf 524.288 Bytes erhöhten Limit ließen sich zehn eingebaute und 51 externe Beispiele einschließlich gültiger Tutor-Daten laden. + +Zwei Probleme wirken zusammen: Die Commit-Auflösung lädt unnötig Commit-Metadaten und Dateidiffs; das Menü erhält seine eingebauten Beispiele erst, wenn der gemeinsame Katalog einschließlich externer Quelle vollständig geladen ist. + +## Alternativen und Empfehlung + +| Ansatz | Nutzen | Grenze / Aufwand | +| --- | --- | --- | +| Antwortlimit erhöhen | Kurzfristige Abhilfe | Ein späterer großer Commit kann erneut scheitern; lokale Beispiele bleiben an GitHub gekoppelt. | +| Kompakte SHA-Auflösung und Teilfehler im bisherigen Katalog | Behebt die Ursache und erlaubt einen gemeinsamen Request | Verändert die Fehler- und Metadatenverträge von `/api/examples`; lokale Beispiele warten weiterhin auf dessen Abschluss. | +| Kompakte SHA-Auflösung und separater eingebauter Katalog | Behebt die Ursache; lokale Beispiele laden unabhängig; bisheriger externer Vertrag bleibt erhalten | Ein zusätzlicher lokaler API-Request und getrennte Ladezustände im Menü. | + +Gewählt ist der dritte Ansatz. Die unabhängigen Teile sind in getrennten Commits umgesetzt. + +## Teil 1: Kleine und begrenzte Commit-Auflösung + +- `GitHubRevisionResolver` verwendet denselben kontrollierten Endpoint `/repos/{owner}/{repo}/commits/{ref}` mit `Accept: application/vnd.github.sha`. +- Der sichere Fetcher erhält eine optionale, ausschließlich serverseitig gesetzte Repräsentation `github-commit-sha`. Er nimmt keine frei übergebenen Header entgegen. +- Für diese Repräsentation ist ausschließlich `api.github.com` mit einem Commit-Endpoint zulässig. Raw-Content-Requests behalten ihre bisherigen Header. +- Ein eigenes festes Antwortlimit von **256 Bytes** gilt für die SHA-Antwort. Nach `trim()` muss `fullCommitShaSchema` exakt einen kleingeschriebenen SHA mit 40 Hex-Zeichen akzeptieren. Ein JSON-Fallback auf die große Commit-Antwort ist nicht vorgesehen. +- `UNOSIM_EXAMPLES_MAX_MANIFEST_BYTES` bleibt standardmäßig **262.144 Bytes** und gilt für das Manifest. Die vorgeschlagene Korrektur benötigt keine Erhöhung dieser Einstellung. +- Timeouts, Abbruchsignale, Streaming-Limits, DNS-/Host-Prüfung, Redirect-Verbot, GitHub-Quota und optionale Authentifizierung bleiben aktiv. Der Token wird weiterhin ausschließlich an `api.github.com` übertragen. +- Ein Größenfehler erhält eine intern unterscheidbare Fehlerklasse. Die Ref-Auflösung protokolliert bei Fehlern Phase, Request-ID und sichere Fehler-Metadaten, keine Tokens, Antworttexte oder vollständigen Fehlernachrichten. Öffentliche Fehlercodes bleiben unverändert. + +GitHub dokumentiert diese Repräsentation unter [Get a commit](https://docs.github.com/en/rest/commits/commits#get-a-commit). Die Live-Diagnose lieferte damit einen 40 Bytes langen SHA, identisch mit dem SHA der JSON-Antwort. + +## Teil 2: Eingebaute Beispiele unabhängig anbieten + +### API und Repository + +Neuer read-only Endpoint: `GET /api/examples/builtin/catalog`. + +Er verwendet `ExamplesRepository.getBuiltInCatalog(): Promise` und denselben `BuiltInProvider` wie die bisherigen Routen. Die Antwort entspricht dem bestehenden Built-in-Katalog: `schemaVersion: 1`, `source.mode: builtin`, `source.status: builtin`, `revision: null`, `stale: false`, ausschließlich Einträge mit `source: builtin`. + +Der Endpoint akzeptiert keine Auswahlparameter. Seine Berechtigungen entsprechen dem bisherigen Default-Katalog: auch im Gateway-Profil sind anonyme Default-Lesezugriffe erlaubt. Er löst keinen Ref auf und verbraucht kein GitHub- oder Source-Load-Kontingent. Die zwei zusätzlichen Pfadsegmente vermeiden eine Kollision mit dem bisherigen Detailpfad `/api/examples/:id`. + +`GET /api/examples` behält sein bisheriges Verhalten einschließlich gemeinsamem Erfolgskatalog und kontrollierter externer Fehler. Validate/Apply bleiben transaktional. Ein vorhandener letzter gültiger Snapshot darf weiterhin ausschließlich für dasselbe Repository und denselben Ref verwendet werden. Weder neue Statuswerte noch eine neue Schema-Version sind erforderlich. + +### Client und Menü + +- Beim Öffnen startet das Menü beide Katalogabrufe parallel und verarbeitet ihre Ergebnisse einzeln. +- `loadBuiltInExamplesCatalog(signal?)` speichert keine externe Auswahl und schreibt nicht in den globalen externen Katalogzustand. Es nutzt einen eigenen Abbruchcontroller. +- Lokale Einträge kommen aus dem neuen Endpoint. Aus dem bisherigen gemeinsamen Katalog übernimmt das Menü ausschließlich `source: external`. So entstehen keine doppelten lokalen Einträge. +- Beide Gruppen haben getrennte Lade- und Fehlerzustände. Eine Gruppe bleibt bedienbar, während die andere lädt oder fehlschlägt. +- Bei einem externen Fehler zeigt das Menü eine dauerhafte Statusmeldung und eine Schaltfläche `Retry`. Die bestehende englische UI-Sprache bleibt erhalten. Vorgeschlagener Text: `External examples are currently unavailable. Built-in examples are still available.` +- Bei einem Fehler der lokalen Gruppe bleibt eine erfolgreich geladene externe Gruppe nutzbar; ihre Statusmeldung nennt ausschließlich den Ausfall der eingebauten Beispiele. Erst wenn beide Gruppen erfolgreich leer sind, ist `No examples available` korrekt. +- Retry lädt ausschließlich die fehlgeschlagene Gruppe neu. Es gibt keine automatischen Wiederholungsschleifen. Geschlossene Menüs und überholte Antworten dürfen keinen neueren Auswahlzustand überschreiben. +- Die bisherige Quellzuordnung externer Details zu Repository und Revision bleibt erhalten. Laden eines eingebauten Beispiels setzt den aktiven externen Beispielkontext wie bisher zurück. Ein Fehler, Retry oder Katalogabruf verändert weder Editorinhalt noch Browser-Präferenz. +- Die Logik für Board-Rückfragen, Tastaturbedienung und genau einen geöffneten Quell-/Kategorieabschnitt bleibt bestehen. Das neue Built-in-Ergebnis darf insbesondere nicht als erfolgreicher externer Katalog an den Tutor weitergereicht werden. + +Die Unabhängigkeit betrifft die externe Quelle. Bei einem Ausfall des UnoSim-Backends gilt weiterhin das bestehende Offline-Verhalten; ein neuer Browser-Offlinecache ist nicht Teil des Vorschlags. + +## Tests und Abnahme + +1. Branch, Tag und vollständiger SHA werden auf genau einen validierten SHA aufgelöst; unbekannte Refs, malformed/überlange Antworten, Abbruch, Redirect, private DNS-Ziele und Rate Limits bleiben kontrollierte Fehler. +2. Ein großer Commit beeinflusst den Abruf nicht, weil der Request die SHA-Repräsentation anfordert; die Tests prüfen den tatsächlichen Accept-Header und beide Größenprüfungen. +3. Der neue Endpoint liefert eingebautes Material bei absichtlich fehlschlagendem oder nie beantwortetem Source-Provider; kein externer Provideraufruf und kein Quota-Verbrauch. +4. Im Browser sind lokale Beispiele sichtbar und ladbar, bevor ein zurückgehaltener externer Request endet; externe Fehler zeigen Status und Retry. +5. Wiederherstellung, Quellwechsel während laufender Requests, Abbruch sowie Ausfall jeweils nur einer Gruppe erhalten Editor und Auswahl. Erfolgreiche externe Beispiele erscheinen genau einmal und behalten ihre Revision. +6. Reale Abnahme nach Serverneustart bei Default-Konfiguration: aktueller Stand zehn lokale und 51 externe Beispiele; direkte Details und ein externer Tutor-Kontext sind gültig. Diese Zahlen sind eine zeitgebundene Abnahmeevidenz, keine dauerhaft festgeschriebenen Inhaltszahlen in Unit-Tests. + +Neue Regressionstests ergänzen die bestehenden Spezifikationen. Unvermeidbare Anpassungen bestehender Test-Erwartungen werden im Umsetzungsplan einzeln begründet; sie dürfen nicht pauschal durch neue Snapshots ersetzt werden. + +## Dokumentation und Einordnung + +Die Umsetzung aktualisiert zuerst `ssot/ssot_function_definition_ExternalExamples.md`, anschließend ADR 0005, `docs/ARCHITECTURE.md`, `docs/SECURITY.md`, `docs/USER_GUIDE.md`, `docs/INSTALL_LOCAL.md` und `docs/INSTALL_SERVER.md`. `docs/TESTING_STANDARDS.md` erhält die deterministischen Ausfallszenarien. Betreiber erfahren, dass eine Limit-Erhöhung nur eine Zwischenlösung war und nach der Korrektur nicht erforderlich ist. Das Hinzufügen des Example-Repos zum IDE-Workspace ändert die GitHub-Quelle des laufenden Servers nicht. + +Das Example-Repository, seine Beispiele und sein Manifest benötigen für diese Korrektur keine Änderung. Seine Content-Validierung war erfolgreich. Für den Browser-Emulationszweig wird der Abschluss knapp im vorhandenen Statusdokument vermerkt. + +Die Entwurfsdateien werden bei Annahme und Umsetzung überprüft; normative Dokumentation beschreibt erst dann das neue Verhalten. Erledigte Pläne werden gemäß `docs/README.md` nach Abschluss entfernt und bleiben in der Git-Historie auffindbar. + +## Ergänzungen aus Umsetzung und Review + +Ein durch einen anderen Katalogverbraucher abgebrochener externer Abruf endet +im offenen Menü mit Retry statt einem dauerhaften Ladezustand. Beim +Quellenwechsel ignoriert außerdem ein bereits gestarteter Detailabruf die alte +Antwort, bevor er Editor oder Tutor-Kontext verändert. Auch ungültige SHA-Texte +werden mit einer festen, inhaltsfreien `invalid-sha`-Diagnose protokolliert. +Diese Fälle wurden jeweils mit zuerst fehlgeschlagenen Regressionstests geprüft. diff --git a/tests/server/examples/http-provider-representation.test.ts b/tests/server/examples/http-provider-representation.test.ts index 5694fdbec..7bd43f2df 100644 --- a/tests/server/examples/http-provider-representation.test.ts +++ b/tests/server/examples/http-provider-representation.test.ts @@ -28,7 +28,7 @@ describe("GitHub SHA HTTP representation", () => { }); it.each(["https://raw.githubusercontent.com/owner/repo/main/file.ino", "https://api.github.com/rate_limit"])("refuses the SHA representation outside a commit URL: %s", async (input) => { - const fetchMock = vi.fn(async () => new Response(sha)); + const fetchMock = vi.fn(async (_url: URL, _init?: RequestInit) => new Response(sha)); vi.stubGlobal("fetch", fetchMock); await expect(new SecureExamplesFetcher().fetchText(new URL(input), 256, undefined, options)).rejects.toThrow(/representation/i); expect(fetchMock).not.toHaveBeenCalled(); @@ -36,7 +36,7 @@ describe("GitHub SHA HTTP representation", () => { it("combines the SHA header with the API token without forwarding either to raw content", async () => { config.examples.githubToken = "test-token"; - const fetchMock = vi.fn(async () => new Response(sha)); + const fetchMock = vi.fn(async (_url: URL, _init?: RequestInit) => new Response(sha)); vi.stubGlobal("fetch", fetchMock); const fetcher = new SecureExamplesFetcher(); await fetcher.fetchText(url, 256, undefined, options); @@ -54,7 +54,7 @@ describe("GitHub SHA HTTP representation", () => { it("does not send an already-aborted SHA request", async () => { const controller = new AbortController(); controller.abort(); - const fetchMock = vi.fn(async () => new Response(sha)); vi.stubGlobal("fetch", fetchMock); + const fetchMock = vi.fn(async (_url: URL, _init?: RequestInit) => new Response(sha)); vi.stubGlobal("fetch", fetchMock); await expect(new SecureExamplesFetcher().fetchText(url, 256, controller.signal, options)).rejects.toMatchObject({ name: "AbortError" }); expect(fetchMock).not.toHaveBeenCalled(); }); From 42b419151119290a512bb55fb5d83291baccca5b Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 23 Oct 2026 23:37:03 +0200 Subject: [PATCH 7/7] refactor(examples): satisfy menu quality gate --- .../src/components/features/examples-menu.tsx | 43 ++++++++++--------- .../plans/2026-10-23-example-loading.md | 5 ++- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/client/src/components/features/examples-menu.tsx b/client/src/components/features/examples-menu.tsx index f940fd621..02b176723 100644 --- a/client/src/components/features/examples-menu.tsx +++ b/client/src/components/features/examples-menu.tsx @@ -271,22 +271,8 @@ export function ExamplesMenu({ } setLoadingExampleId(example.id); try { - let query = ""; - if (example.source === "external") { - if (!example.repository || !example.revision) { - throw new Error("Example catalog has no revision context"); - } - query = `?repository=${encodeURIComponent(example.repository)}&revision=${encodeURIComponent(example.revision)}`; - } - const response = await fetch( - `/api/examples/${encodeURIComponent(example.id)}${query}`, - ); - if (!response.ok) throw new Error("Failed to fetch example"); - const detail = (await response.json()) as ExampleDetail; + const detail = await loadExampleDetail(example); if (!capabilitiesRef.current.canUseServerExamples || selection !== selectionRef.current) return; - if (!Array.isArray(detail.files) || detail.files.length === 0) { - throw new Error("Example contains no files"); - } const displayName = getExampleDisplayName(example); setActiveExternalExampleContext(example.source === "external" && example.repository && example.ref && example.revision ? { repository: example.repository, ref: example.ref, revision: example.revision, exampleId: example.id } @@ -363,15 +349,15 @@ export function ExamplesMenu({ {(["builtin", "external"] as const).map(source => (
{groups[source].loading && ( -
+ Loading {source === "builtin" ? "built-in" : "external"} examples... -
+ )} {groups[source].error && ( -
-

{source === "builtin" ? "Built-in" : "External"} examples are currently unavailable. +

+ {source === "builtin" ? "Built-in" : "External"} examples are currently unavailable. {source === "external" && groups.builtin.items.length > 0 ? " Built-in examples are still available." : ""} -

+
)} @@ -428,6 +414,23 @@ function getExampleDisplayName(example: Example): string { ); } +async function loadExampleDetail(example: Example): Promise { + let query = ""; + if (example.source === "external") { + if (!example.repository || !example.revision) { + throw new Error("Example catalog has no revision context"); + } + query = `?repository=${encodeURIComponent(example.repository)}&revision=${encodeURIComponent(example.revision)}`; + } + const response = await fetch(`/api/examples/${encodeURIComponent(example.id)}${query}`); + if (!response.ok) throw new Error("Failed to fetch example"); + const detail = (await response.json()) as ExampleDetail; + if (!Array.isArray(detail.files) || detail.files.length === 0) { + throw new Error("Example contains no files"); + } + return detail; +} + interface ExampleItemProps { readonly example: Example; readonly onLoadExample: (example: Example) => void; diff --git a/docs/superpowers/plans/2026-10-23-example-loading.md b/docs/superpowers/plans/2026-10-23-example-loading.md index dea5648a6..4b457632d 100644 --- a/docs/superpowers/plans/2026-10-23-example-loading.md +++ b/docs/superpowers/plans/2026-10-23-example-loading.md @@ -153,13 +153,14 @@ - Der im Review benannte bereits bestehende Detail-/Quellenwechsel-Race wurde wegen möglichem Überschreiben von Editor/Tutor mit behoben. Eine alte Auswahl wird verworfen, sobald die Quelle gewechselt wurde. - Der Diagnosehinweis aus dem Review wurde als ausdrücklich geplante Funktion vollständig umgesetzt: Auch fehlerhafter SHA-Text erhält einen sicheren Logeintrag. - Unabhängiges Code-Review und ein anschließender Red-Green-Fixdurchlauf sind erfolgt; es wurden keine Befunde zurückgestellt. +- Das erste Pre-Push-Sonar-Gate meldete drei Befunde im Menü: kognitive Komplexität und zwei Statusrollen. Der Detailabruf ist dafür in eine eigene Funktion ausgelagert, die Statusmeldungen verwenden natives `output`; die vorhandenen Verhaltensprüfungen bleiben unverändert. Hooks und Gate bleiben aktiv. ## Verifizierter Abschluss - Node.js 24.20.0; TypeScript, ESLint, Dokumentationsprüfung und Build bestanden. - 3.594 App-Unit-Tests und 635 Emulator-Unit-Tests bestanden; ein vorhandener Test übersprungen. - 126 Toolchain-Integrationstests bestanden; ein vorhandener schwerer Test übersprungen. -- 53 Browser-Tests bestanden; isolierter Lauf auf Port 3100. -- Initialer JavaScript-Ladepfad: 690.529 Bytes bei unverändertem Limit 710.000 Bytes. +- 53 Browser-Tests bestanden; isolierter Lauf auf Port 3100. Im letzten Lauf nach dem Sonar-Refactoring benötigte ein vorhandener Monaco-Starttest den konfigurierten Retry; alle Beispieltests bestanden direkt. +- Initialer JavaScript-Ladepfad: 690.578 Bytes bei unverändertem Limit 710.000 Bytes. - Reale Quelle mit Standardlimit: zehn eingebaute und 51 externe Beispiele, Tutor-Daten gültig. - Beide abschließenden Review-Korrekturen durch Red-Green-Regressionstests und erneute vollständige Läufe geprüft.