From e5b784e1d31e422361fb6facc438b7cfe779c2c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:53:13 +0000 Subject: [PATCH 1/2] Initial plan From 44c3b2c029d6f5d5a5727e51dd050b6f16a8a523 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 23:05:26 +0000 Subject: [PATCH 2/2] feat: increase test coverage and add centralized test results infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 39 new tests for @ag-ui/core capabilities schemas (SubAgentInfo, Identity, Transport, Tools, Output, State, MultiAgent, Reasoning, MultimodalInput/Output, Multimodal, Execution, HumanInTheLoop, AgentCapabilities) — previously untested - Add 16 new tests for @ag-ui/encoder preferredMediaTypes utility (undefined/empty accept, exact match, wildcard, quality values, parameters, case insensitivity, complex headers) — previously untested - Update vitest.base.ts: add JUnit XML and JSON reporters when CI=true so every CI run writes machine-readable results that can be uploaded as artifacts - Update unit-typescript-sdk.yml: run test:coverage instead of test, upload unit-test-results (90-day) and unit-coverage-reports (30-day) as artifacts - Add test-results/README.md documenting centralized results layout, reporters in use, and historical storage recommendations (Codecov, SonarCloud, Allure, self-hosted S3) - Update .gitignore: ignore all package-level test-results/ dirs while keeping the root test-results/README.md tracked Agent-Logs-Url: https://github.com/jamesmkfoo23/ag-ui/sessions/625386c6-228e-4923-bb60-babfcd82d937 Co-authored-by: jamesmkfoo23 <45367108+jamesmkfoo23@users.noreply.github.com> --- .github/workflows/unit-typescript-sdk.yml | 29 +- .gitignore | 6 +- .../core/src/__tests__/capabilities.test.ts | 421 ++++++++++++++++++ .../encoder/src/__tests__/media-type.test.ts | 128 ++++++ sdks/typescript/vitest.base.ts | 11 + test-results/README.md | 104 +++++ 6 files changed, 696 insertions(+), 3 deletions(-) create mode 100644 sdks/typescript/packages/core/src/__tests__/capabilities.test.ts create mode 100644 sdks/typescript/packages/encoder/src/__tests__/media-type.test.ts create mode 100644 test-results/README.md diff --git a/.github/workflows/unit-typescript-sdk.yml b/.github/workflows/unit-typescript-sdk.yml index 2e42803d6b..f1ab143eaa 100644 --- a/.github/workflows/unit-typescript-sdk.yml +++ b/.github/workflows/unit-typescript-sdk.yml @@ -62,5 +62,30 @@ jobs: - name: Test Build run: pnpm run build - - name: Run tests - run: pnpm run test + - name: Run tests with coverage + run: pnpm run test:coverage + + - name: Upload test results (JUnit XML) + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-test-results + path: | + sdks/typescript/packages/*/test-results/junit.xml + sdks/typescript/packages/*/test-results/results.json + integrations/**/test-results/junit.xml + integrations/**/test-results/results.json + middlewares/**/test-results/junit.xml + middlewares/**/test-results/results.json + retention-days: 90 + + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-coverage-reports + path: | + sdks/typescript/packages/*/coverage/ + integrations/**/coverage/ + middlewares/**/coverage/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 1f5fecf030..8fb764da08 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,11 @@ mastra.db* **/.DS_Store -test-results/ +# Ignore generated test results but keep the root README documenting the storage strategy +test-results/** +!test-results/README.md +# Ignore package-level test-results directories (created by vitest JUnit/JSON reporters in CI) +**/test-results/ **/target .nx/cache diff --git a/sdks/typescript/packages/core/src/__tests__/capabilities.test.ts b/sdks/typescript/packages/core/src/__tests__/capabilities.test.ts new file mode 100644 index 0000000000..e53ebade43 --- /dev/null +++ b/sdks/typescript/packages/core/src/__tests__/capabilities.test.ts @@ -0,0 +1,421 @@ +import { + AgentCapabilitiesSchema, + ExecutionCapabilitiesSchema, + HumanInTheLoopCapabilitiesSchema, + IdentityCapabilitiesSchema, + MultiAgentCapabilitiesSchema, + MultimodalCapabilitiesSchema, + MultimodalInputCapabilitiesSchema, + MultimodalOutputCapabilitiesSchema, + OutputCapabilitiesSchema, + ReasoningCapabilitiesSchema, + StateCapabilitiesSchema, + SubAgentInfoSchema, + ToolsCapabilitiesSchema, + TransportCapabilitiesSchema, +} from "../capabilities"; + +describe("SubAgentInfoSchema", () => { + it("parses a sub-agent with name only", () => { + const result = SubAgentInfoSchema.safeParse({ name: "search-agent" }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.name).toBe("search-agent"); + }); + + it("parses a sub-agent with name and description", () => { + const result = SubAgentInfoSchema.safeParse({ + name: "code-agent", + description: "Writes and reviews code", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe("code-agent"); + expect(result.data.description).toBe("Writes and reviews code"); + } + }); + + it("fails when name is missing", () => { + const result = SubAgentInfoSchema.safeParse({ description: "no name" }); + expect(result.success).toBe(false); + }); +}); + +describe("IdentityCapabilitiesSchema", () => { + it("parses an empty object (all fields optional)", () => { + expect(IdentityCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses a fully-populated identity", () => { + const result = IdentityCapabilitiesSchema.safeParse({ + name: "My Agent", + type: "langgraph", + description: "Does things", + version: "1.2.3", + provider: "Acme Corp", + documentationUrl: "https://example.com/docs", + metadata: { region: "us-east-1" }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe("My Agent"); + expect(result.data.type).toBe("langgraph"); + expect(result.data.version).toBe("1.2.3"); + expect(result.data.metadata).toEqual({ region: "us-east-1" }); + } + }); + + it("parses when only name is provided", () => { + const result = IdentityCapabilitiesSchema.safeParse({ name: "Minimal Agent" }); + expect(result.success).toBe(true); + }); +}); + +describe("TransportCapabilitiesSchema", () => { + it("parses an empty object", () => { + expect(TransportCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses all transport flags set to true", () => { + const result = TransportCapabilitiesSchema.safeParse({ + streaming: true, + websocket: true, + httpBinary: true, + pushNotifications: true, + resumable: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.streaming).toBe(true); + expect(result.data.websocket).toBe(true); + expect(result.data.httpBinary).toBe(true); + expect(result.data.pushNotifications).toBe(true); + expect(result.data.resumable).toBe(true); + } + }); + + it("parses with only streaming enabled", () => { + const result = TransportCapabilitiesSchema.safeParse({ streaming: true }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.streaming).toBe(true); + }); + + it("fails when a flag is not a boolean", () => { + const result = TransportCapabilitiesSchema.safeParse({ streaming: "yes" }); + expect(result.success).toBe(false); + }); +}); + +describe("ToolsCapabilitiesSchema", () => { + it("parses an empty object", () => { + expect(ToolsCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses with supported flag", () => { + const result = ToolsCapabilitiesSchema.safeParse({ + supported: true, + parallelCalls: false, + clientProvided: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.supported).toBe(true); + expect(result.data.parallelCalls).toBe(false); + } + }); + + it("parses with an items array", () => { + const result = ToolsCapabilitiesSchema.safeParse({ + supported: true, + items: [ + { + name: "web_search", + description: "Search the web", + parameters: { type: "object", properties: {} }, + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.items).toHaveLength(1); + expect(result.data.items![0].name).toBe("web_search"); + } + }); +}); + +describe("OutputCapabilitiesSchema", () => { + it("parses an empty object", () => { + expect(OutputCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses structured output with MIME types", () => { + const result = OutputCapabilitiesSchema.safeParse({ + structuredOutput: true, + supportedMimeTypes: ["application/json", "text/plain"], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.structuredOutput).toBe(true); + expect(result.data.supportedMimeTypes).toEqual(["application/json", "text/plain"]); + } + }); +}); + +describe("StateCapabilitiesSchema", () => { + it("parses an empty object", () => { + expect(StateCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses all state flags", () => { + const result = StateCapabilitiesSchema.safeParse({ + snapshots: true, + deltas: true, + memory: false, + persistentState: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.snapshots).toBe(true); + expect(result.data.deltas).toBe(true); + expect(result.data.memory).toBe(false); + expect(result.data.persistentState).toBe(true); + } + }); +}); + +describe("MultiAgentCapabilitiesSchema", () => { + it("parses an empty object", () => { + expect(MultiAgentCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses with sub-agents list", () => { + const result = MultiAgentCapabilitiesSchema.safeParse({ + supported: true, + delegation: true, + handoffs: false, + subAgents: [{ name: "child-agent", description: "Does subtasks" }], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.subAgents).toHaveLength(1); + expect(result.data.subAgents![0].name).toBe("child-agent"); + } + }); + + it("fails when sub-agent name is missing", () => { + const result = MultiAgentCapabilitiesSchema.safeParse({ + subAgents: [{ description: "no name" }], + }); + expect(result.success).toBe(false); + }); +}); + +describe("ReasoningCapabilitiesSchema", () => { + it("parses an empty object", () => { + expect(ReasoningCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses all reasoning flags", () => { + const result = ReasoningCapabilitiesSchema.safeParse({ + supported: true, + streaming: true, + encrypted: false, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.supported).toBe(true); + expect(result.data.streaming).toBe(true); + expect(result.data.encrypted).toBe(false); + } + }); +}); + +describe("MultimodalInputCapabilitiesSchema", () => { + it("parses an empty object", () => { + expect(MultimodalInputCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses all modalities enabled", () => { + const result = MultimodalInputCapabilitiesSchema.safeParse({ + image: true, + audio: true, + video: true, + pdf: true, + file: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.image).toBe(true); + expect(result.data.audio).toBe(true); + expect(result.data.video).toBe(true); + expect(result.data.pdf).toBe(true); + expect(result.data.file).toBe(true); + } + }); + + it("parses with only image enabled", () => { + const result = MultimodalInputCapabilitiesSchema.safeParse({ image: true }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.image).toBe(true); + }); +}); + +describe("MultimodalOutputCapabilitiesSchema", () => { + it("parses an empty object", () => { + expect(MultimodalOutputCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses image and audio output enabled", () => { + const result = MultimodalOutputCapabilitiesSchema.safeParse({ + image: true, + audio: false, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.image).toBe(true); + expect(result.data.audio).toBe(false); + } + }); +}); + +describe("MultimodalCapabilitiesSchema", () => { + it("parses an empty object", () => { + expect(MultimodalCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses nested input and output capabilities", () => { + const result = MultimodalCapabilitiesSchema.safeParse({ + input: { image: true, audio: false, video: false, pdf: true, file: true }, + output: { image: true, audio: false }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.input?.image).toBe(true); + expect(result.data.output?.image).toBe(true); + } + }); +}); + +describe("ExecutionCapabilitiesSchema", () => { + it("parses an empty object", () => { + expect(ExecutionCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses code execution with limits", () => { + const result = ExecutionCapabilitiesSchema.safeParse({ + codeExecution: true, + sandboxed: true, + maxIterations: 10, + maxExecutionTime: 30000, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.codeExecution).toBe(true); + expect(result.data.sandboxed).toBe(true); + expect(result.data.maxIterations).toBe(10); + expect(result.data.maxExecutionTime).toBe(30000); + } + }); + + it("fails when maxIterations is not a number", () => { + const result = ExecutionCapabilitiesSchema.safeParse({ maxIterations: "ten" }); + expect(result.success).toBe(false); + }); +}); + +describe("HumanInTheLoopCapabilitiesSchema", () => { + it("parses an empty object", () => { + expect(HumanInTheLoopCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses all HITL flags", () => { + const result = HumanInTheLoopCapabilitiesSchema.safeParse({ + supported: true, + approvals: true, + interventions: false, + feedback: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.supported).toBe(true); + expect(result.data.approvals).toBe(true); + expect(result.data.interventions).toBe(false); + expect(result.data.feedback).toBe(true); + } + }); +}); + +describe("AgentCapabilitiesSchema", () => { + it("parses an empty object (fully optional)", () => { + expect(AgentCapabilitiesSchema.safeParse({}).success).toBe(true); + }); + + it("parses a minimal agent with only identity", () => { + const result = AgentCapabilitiesSchema.safeParse({ + identity: { name: "Simple Agent" }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.identity?.name).toBe("Simple Agent"); + } + }); + + it("parses a fully-populated agent capabilities object", () => { + const result = AgentCapabilitiesSchema.safeParse({ + identity: { + name: "Full Agent", + type: "mastra", + version: "2.0.0", + provider: "Acme", + }, + transport: { streaming: true, websocket: false }, + tools: { supported: true, parallelCalls: true, clientProvided: true }, + output: { structuredOutput: true }, + state: { snapshots: true, deltas: true, memory: true, persistentState: true }, + multiAgent: { + supported: true, + delegation: true, + handoffs: true, + subAgents: [{ name: "sub-1" }], + }, + reasoning: { supported: true, streaming: true, encrypted: false }, + multimodal: { + input: { image: true, audio: true, video: false, pdf: false, file: false }, + output: { image: false, audio: false }, + }, + execution: { codeExecution: true, sandboxed: true, maxIterations: 5 }, + humanInTheLoop: { supported: true, approvals: true }, + custom: { proprietary_flag: true }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.identity?.name).toBe("Full Agent"); + expect(result.data.transport?.streaming).toBe(true); + expect(result.data.tools?.supported).toBe(true); + expect(result.data.state?.persistentState).toBe(true); + expect(result.data.multiAgent?.subAgents).toHaveLength(1); + expect(result.data.reasoning?.streaming).toBe(true); + expect(result.data.multimodal?.input?.image).toBe(true); + expect(result.data.execution?.maxIterations).toBe(5); + expect(result.data.humanInTheLoop?.approvals).toBe(true); + expect(result.data.custom).toEqual({ proprietary_flag: true }); + } + }); + + it("strips unknown top-level keys", () => { + const result = AgentCapabilitiesSchema.safeParse({ + identity: { name: "Agent" }, + unknownKey: "should be stripped", + }); + expect(result.success).toBe(true); + if (result.success) { + expect("unknownKey" in result.data).toBe(false); + } + }); + + it("fails when a nested field has the wrong type", () => { + const result = AgentCapabilitiesSchema.safeParse({ + execution: { maxIterations: "not-a-number" }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/sdks/typescript/packages/encoder/src/__tests__/media-type.test.ts b/sdks/typescript/packages/encoder/src/__tests__/media-type.test.ts new file mode 100644 index 0000000000..6a836ab561 --- /dev/null +++ b/sdks/typescript/packages/encoder/src/__tests__/media-type.test.ts @@ -0,0 +1,128 @@ +import { preferredMediaTypes } from "../media-type"; + +describe("preferredMediaTypes", () => { + describe("when no accept header is provided", () => { + it("returns all provided types when accept is undefined", () => { + const result = preferredMediaTypes(undefined, ["text/html", "application/json"]); + expect(result).toEqual(["text/html", "application/json"]); + }); + + it("returns all provided types when accept is empty string", () => { + // empty string → no types accepted → no matches + const result = preferredMediaTypes("", ["text/html"]); + expect(result).toEqual([]); + }); + }); + + describe("when provided list is omitted", () => { + it("returns sorted list of accepted types from the Accept header", () => { + const result = preferredMediaTypes("text/html, application/json;q=0.9"); + expect(result).toContain("text/html"); + expect(result).toContain("application/json"); + // text/html should come first (higher q) + expect(result.indexOf("text/html")).toBeLessThan(result.indexOf("application/json")); + }); + + it("returns empty array when Accept header has q=0 for all types", () => { + const result = preferredMediaTypes("text/html;q=0"); + expect(result).toEqual([]); + }); + + it("returns wildcard type */* when no provided list is given", () => { + const result = preferredMediaTypes("*/*"); + expect(result).toContain("*/*"); + }); + }); + + describe("exact type matching", () => { + it("returns a type that exactly matches the Accept header", () => { + const result = preferredMediaTypes("application/json", ["application/json", "text/html"]); + expect(result).toEqual(["application/json"]); + }); + + it("returns empty array when provided type does not match Accept header", () => { + const result = preferredMediaTypes("application/json", ["text/html"]); + expect(result).toEqual([]); + }); + + it("returns types in quality order", () => { + const result = preferredMediaTypes( + "text/html;q=0.5, application/json;q=0.9", + ["text/html", "application/json"], + ); + expect(result[0]).toBe("application/json"); + expect(result[1]).toBe("text/html"); + }); + }); + + describe("wildcard matching", () => { + it("matches any type with */* in Accept header", () => { + const result = preferredMediaTypes("*/*", ["application/json", "text/html"]); + expect(result).toContain("application/json"); + expect(result).toContain("text/html"); + }); + + it("matches any subtype with type/* in Accept header", () => { + const result = preferredMediaTypes("text/*", ["text/html", "text/plain", "application/json"]); + expect(result).toContain("text/html"); + expect(result).toContain("text/plain"); + expect(result).not.toContain("application/json"); + }); + }); + + describe("quality values", () => { + it("excludes types with q=0", () => { + const result = preferredMediaTypes("text/html;q=0, application/json", [ + "text/html", + "application/json", + ]); + expect(result).not.toContain("text/html"); + expect(result).toContain("application/json"); + }); + + it("treats missing q value as q=1", () => { + const result = preferredMediaTypes("text/html", ["text/html", "application/json"]); + expect(result).toContain("text/html"); + }); + + it("sorts multiple types by descending quality", () => { + const result = preferredMediaTypes( + "text/plain;q=0.3, text/html;q=0.8, application/json;q=0.5", + ["text/plain", "text/html", "application/json"], + ); + expect(result[0]).toBe("text/html"); + expect(result[1]).toBe("application/json"); + expect(result[2]).toBe("text/plain"); + }); + }); + + describe("parameter handling", () => { + it("matches a type with matching parameters", () => { + const result = preferredMediaTypes("text/html;level=1", ["text/html;level=1", "text/html"]); + // text/html;level=1 should be a more specific match + expect(result).toContain("text/html;level=1"); + }); + }); + + describe("case insensitivity", () => { + it("matches types case-insensitively", () => { + const result = preferredMediaTypes("Application/JSON", ["application/json"]); + expect(result).toContain("application/json"); + }); + }); + + describe("multiple accept types", () => { + it("handles a complex Accept header with multiple entries", () => { + const accept = "text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8"; + const provided = ["application/json", "text/html", "application/xml"]; + const result = preferredMediaTypes(accept, provided); + // text/html matches exactly (q=1), application/xml matches (q=0.9), + // application/json matches via */* (q=0.8) + expect(result).toContain("text/html"); + expect(result).toContain("application/xml"); + expect(result).toContain("application/json"); + expect(result.indexOf("text/html")).toBeLessThan(result.indexOf("application/xml")); + expect(result.indexOf("application/xml")).toBeLessThan(result.indexOf("application/json")); + }); + }); +}); diff --git a/sdks/typescript/vitest.base.ts b/sdks/typescript/vitest.base.ts index d53c9c4269..1f7badf8b8 100644 --- a/sdks/typescript/vitest.base.ts +++ b/sdks/typescript/vitest.base.ts @@ -18,6 +18,17 @@ export default defineConfig({ environment: "node", include: ["**/*.test.ts"], passWithNoTests: true, + // In CI, emit JUnit XML and JSON reports so results can be uploaded as artifacts. + // Locally, only the default reporter is used to keep output readable. + reporters: process.env.CI + ? ["verbose", "junit", "json"] + : ["verbose"], + outputFile: process.env.CI + ? { + junit: "./test-results/junit.xml", + json: "./test-results/results.json", + } + : undefined, coverage: { provider: "istanbul", reporter: ["text", "json", "html"], diff --git a/test-results/README.md b/test-results/README.md new file mode 100644 index 0000000000..3c690b6662 --- /dev/null +++ b/test-results/README.md @@ -0,0 +1,104 @@ +# Test Results + +This directory is the designated landing zone for all test output in the ag-ui monorepo. +Its contents are listed in `.gitignore` so that generated files are never committed to the +repository while the directory itself (and this README) are tracked. + +--- + +## What is stored here + +| Subdirectory | Populated by | Contents | +|---|---|---| +| `sdks/typescript/packages//test-results/` | `pnpm test` (CI) | `junit.xml`, `results.json` | +| `sdks/typescript/packages//coverage/` | `pnpm test:coverage` | Istanbul HTML/JSON/text reports | +| `integrations/**/test-results/` | `pnpm test` (CI) | Same as above | +| `middlewares/**/test-results/` | `pnpm test` (CI) | Same as above | +| `apps/dojo/e2e/test-results/` | Playwright | Traces, screenshots, videos for failed tests | +| `apps/dojo/e2e/playwright-report/` | Playwright | HTML report for the most recent e2e run | + +### Reporters in use + +**Unit tests (Vitest)** +- `verbose` — always active, prints each test result to the console. +- `junit` — active in CI only (`CI=true`). Writes a JUnit-compatible XML file that most CI + dashboards and test-result parsers understand. +- `json` — active in CI only. Writes a machine-readable JSON summary. +- `html` (Istanbul coverage) — always generated by `test:coverage`. + +**E2E tests (Playwright)** +- `html` — always generated; open with `pnpm --filter @dojo/e2e report`. +- `github` — active in GitHub Actions; annotates PRs with failing test names. +- `s3-video-reporter` + `playwright-slack-report` — optional, enabled when + `SLACK_WEBHOOK_URL` and `AWS_S3_BUCKET_NAME` env vars are set in CI. + +--- + +## Recommended strategy for historical storage + +### Short term (GitHub Actions artifacts — already configured) + +The `unit-typescript-sdk.yml` workflow uploads two artifact bundles on every run: + +| Artifact | Retention | Contents | +|---|---|---| +| `unit-test-results` | **90 days** | JUnit XML + JSON per package | +| `unit-coverage-reports` | **30 days** | Istanbul HTML/JSON coverage per package | + +The `dojo-e2e.yml` workflow uploads: + +| Artifact | Retention | Contents | +|---|---|---| +| `-playwright-traces` | **7 days** | Playwright traces, HTML report, videos | + +Artifacts are accessible from the **Actions → → Artifacts** section of the repository on GitHub. + +> **Tip:** Increase retention days for the artifact upload steps if longer history is needed +> (maximum 400 days on GitHub.com plans that support extended retention). + +### Medium term (GitHub Actions summary and PR annotations) + +- The JUnit reporter produces output that is automatically parsed by GitHub's built-in test + results UI (visible in the **Summary** tab of a workflow run). +- Use the [test-reporter](https://github.com/dorny/test-reporter) action to publish JUnit XML + as a visual check on the PR — no external service required. + +### Long-term / cross-run trending + +For historical trending and dashboards across many runs, consider: + +1. **Codecov** — free for open-source repos. Upload Istanbul JSON reports in CI with the + [codecov-action](https://github.com/codecov/codecov-action). Provides per-file coverage + diff on every PR and a coverage trend chart. + +2. **SonarCloud** — free for public repos. Parses JUnit XML + Istanbul JSON. Provides + coverage, duplication, and code-smell trends over time. + +3. **Allure Report** — open-source. Use + [allure-vitest](https://github.com/allure-framework/allure-js) to produce Allure-compatible + output from Vitest and host the report on GitHub Pages or a static S3 bucket. Keeps full + history of test durations and flakiness. + +4. **Self-hosted S3/GCS bucket** — store JUnit XML files under a path scheme like + `s3://your-bucket/test-results////`. A simple Athena or BigQuery + query over the XML can surface trends without any additional SaaS. + +--- + +## Running tests locally + +```bash +# Unit tests (all packages) +pnpm test + +# Unit tests with coverage +pnpm test:coverage + +# E2E tests (requires dojo services to be running) +cd apps/dojo/e2e +pnpm test +``` + +Coverage HTML reports open from `sdks/typescript/packages//coverage/index.html`. +Playwright HTML report opens with `pnpm --filter @dojo/e2e report` (or open +`apps/dojo/e2e/playwright-report/index.html` directly).