Skip to content

The port: ParsedResult model, LegacyResultReader, and the renderer migration (#391, Tasks 3–5b) - #443

Merged
tylervick merged 4 commits into
mainfrom
tylervick/parsed-result-391
Aug 12, 2026
Merged

tylervick merged 4 commits into
mainfrom
tylervick/parsed-result-391

Conversation

@tylervick

@tylervick tylervick commented Aug 12, 2026

Copy link
Copy Markdown
Member

Implements migration Tasks 3, 4, 5a, and 5b for #391 (milestone 4.0) as one PR, per the plan's mandate: the safety mechanism is the byte-identical gate between 5a and its pre-refactor baseline, and splitting would create unverifiable intermediate states.

What this PR does

  • Task 3ParsedResult, the backend-neutral port (Classes/ResultReading/ParsedResult.swift), plus the ResultReader and PayloadProviding protocols. Encodes the eight settled Task 2.5 answers: no finish, no activityType, no UTI, neutral ParsedStatus, arguments: [String] slot for Swift Testing.
  • Task 4LegacyResultReader: XCResultKit → ParsedResult, with the repetition merge (Set<TestCase> semantics moved from TestGroup.init) and the failure-summary interleave moved into the reader. TDD'd via LegacyResultReaderTests (5 tests, written failing first).
  • Task 5a — the renderer moves onto ParsedResult with zero behaviour change. ObjectClassNodeKind (same emitted test-summary / test-summary-group classes), ResultFile becomes the legacy PayloadProviding conformer and moves to ResultReading/Legacy/, the missing .logExportFailed fault is recorded on the log write-failure path (with new FaultReportingTests coverage), getCodeCoverage() (dead code) deleted, exportJson() and the payload lock table kept.
  • Task 5b — the model decisions applied to the renderer: ActivityType deleted, per-activity durations render (), Activity.uuid minted from IdentifierPath, and the reader's interleave re-keyed from finish to start (coordinator-ruled; see amendments). JUnitReport's activity mapping reduces to the failure flag.

Every identifierPath: argument stays (#430); HTMLTemplates.swift untouched; status maps through ParsedStatus with .expectedFailure still rendering as .unknown; empty-but-decodable reads return nil so ParsedResult(runs: []) never reaches Summary.init.

Gate results

5a gate — byte identity against the pre-refactor baseline (one fixture generation):

$ diff -r /tmp/xchr-baseline /tmp/xchr-after && echo IDENTICAL
IDENTICAL — refactor is behaviour-preserving

All three fixtures (TestResults, SanityResults, RetryResults), raw bytes, no normalization. Full suite at 5a: 39 tests, 2 skipped, 0 failures. Import gate: grep -rln "import XCResultKit" Sources/ | grep -v ResultReading/Legacy → no output.

5b gate — enumerated diff against the 5a capture. Every changed line machine-classified into the four mandated shapes (classifier in the PR discussion; zero unclassified lines):

Shape Lines
1 — activity class losing activity-internal / activity-user-created / activity-skipped-test / activity-delete-attachment 50
2 — activity row (1.23s)() 64
3 — activity element id: XCResultKit uuid → path digest (68 distinct ids, all [0-9a-f]{32}) 204
4 — failure row repositioned (interleave re-key finishstart) 74

Shape-4 verification per the coordinator's ruling: every moved line pairs a deletion with a content-identical insertion (modulo shape-2/3 edits on the same line, multiset-verified per fixture); the non-failure activity title sequence is hash-identical before and after (md5 3793385a… / ee998611… per fixture), so only failure rows moved; SanityResults (no failures) shows zero shape-4 lines. Exercised exactly where a failure's timestamp falls inside an activity's span: RetryTests.swift:47 during Retryable Activity, FirstSuite.swift:96 during Activity with DoubleQuote…, ThirdSuite.swift:32/36/42 among the test Activity rows. Full suite at 5b: 39 tests, 2 skipped, 0 failures.

Plan and spec amendments (same PR, per the snippets-are-intent rule)

  1. Task 3: ParsedActivity carries three transitional fields (transitionalActivityType, transitionalFinish, transitionalUUID) so 5a can meet byte-identity; Task 5b deletes them. The plan's model snippet was the post-5b shape and had no slot to "carry it through ParsedActivity" as 5a's own text required.
  2. Task 4: the interleave sort is keyed on the transitional finish until 5b — sorting by start in 5a provably reorders RetryResults (failure during Retryable Activity) and would fail the byte gate. Coordinator ruling 2026-08-12: finish-key in 5a, re-key to start in 5b with the repositioning enumerated as shape 4. Also: iteration sort gains a source-position tie-break (same total order the deleted TestCase.merge used).
  3. Task 5a: ResultFile.swift is a move-and-modify to ResultReading/Legacy/ (the import gate is satisfiable no other way); the XCResultKit EmittableOutput extensions move with it. Failure rows keep their historical shallower indent (old failure init defaulted padding to 0) — keyed on ParsedActivity.isFailure.
  4. Task 5b: gains Step 3.5 (the interleave re-key) and shape 4 in its gate; JUnitReport.swift added to its file list — .systemOut / .skipped activity states lose their only producers with ActivityType (their pinning test testRetryFunctionalityJunit is already skipped for CoreTests.testRetryFunctionalityJunit expectations drift with Xcode version #378 fixture drift).
  5. Spec: decision 1's record gains the consequence note (re-key changes legacy render order where a failure falls inside an activity's span; lands in phase 1b with its own enumerated shape; both readers key on start post-5b so the allow-list gains no entry), citing RetryResults / testRetryOnFailure().

Fixture note: fixtures were regenerated once mid-task (the initial set predated testAttachScreenshot, #393/#428) and the baseline was re-captured from the pre-refactor renderer against the new generation, so all gate comparisons are within one fixture generation. A first regeneration attempt hit a cold-simulator UI-runner crash; the retry produced complete bundles (17 test rows, 3 screenshot images verified in the baseline).

Part of #391. Do not merge — an independent review follows.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added broader support for parsing test results, including retries, iterations, activities, failures, attachments, and device details.
    • Improved recognition of attachment types and handling of linked payloads and logs.
    • Added clearer handling of passed, failed, skipped, expected-failure, and unknown test statuses.
  • Bug Fixes

    • Improved failure ordering and activity rendering in generated reports.
    • Preserved warnings and continued processing when individual result data cannot be parsed.
    • Improved reporting of payload and log export failures.

Verified behaviour-preserving: raw renders of all three fixtures are
byte-identical before and after, against one fixture generation.
Activity types and per-activity durations leave the report; activity element
ids become path-derived; the activity/failure interleave re-keys from finish
to start. Diff against the 5a baseline enumerated and reviewed: every line is
one of the four accepted shapes (50 class / 64 time / 204 id / 74 reposition).
@tylervick tylervick added this to the 4.0 milestone Aug 12, 2026
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The report now converts legacy .xcresult data into backend-neutral parsed models. Rendering models consume parsed runs, tests, activities, attachments, statuses, and payload references. Tests cover parsing, repetition merging, attachment handling, destinations, and export faults.

Changes

Parsed-result migration

Layer / File(s) Summary
Parsed-result contracts
Sources/XCTestHTMLReportCore/Classes/ResultReading/ParsedResult.swift, Sources/XCTestHTMLReportCore/Classes/ResultReading/ResultReader.swift, Sources/XCTestHTMLReportCore/Classes/Protocols/*
Added backend-neutral result structures and protocols for result reading, payload export, log export, and test node kinds.
Legacy result parsing
Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/LegacyResultReader.swift
Added conversion from XCResultKit invocation records into parsed runs, destinations, test hierarchies, repetitions, activities, failures, statuses, and attachments.
Payload and log export
Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/ResultFile.swift
Changed payload and log APIs to use references, retained per-reference serialization, recorded export faults, and added recursive activity-log formatting.
Report model integration
Sources/XCTestHTMLReportCore/Classes/Models/Summary.swift, Sources/XCTestHTMLReportCore/Classes/Models/Run.swift, Sources/XCTestHTMLReportCore/Classes/Models/RunDestination.swift, Sources/XCTestHTMLReportCore/Classes/Models/TargetDevice.swift, Sources/XCTestHTMLReportCore/Classes/Models/TestSummary.swift, Sources/XCTestHTMLReportCore/Classes/Models/Test.swift
Updated report models to consume parsed runs and test structures. Repetition merging and ordering now occur in the reader.
Activity and attachment rendering
Sources/XCTestHTMLReportCore/Classes/Models/Activity.swift, Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift, Sources/XCTestHTMLReportCore/Classes/Models/Iteration.swift, Sources/XCTestHTMLReportCore/Classes/Models/JUnitReport.swift
Activities and iterations use parsed failure state and identifier paths. Attachments use filename extensions and payload references. JUnit output maps activity state from failure flags.
Validation and migration documentation
Tests/XCTestHTMLReportTests/*, docs/superpowers/plans/*, docs/superpowers/specs/*
Added parser and export-fault tests. Updated migration documentation for activity ordering, transitional fields, and accepted rendering differences.

Estimated code review effort: 4 (Complex) | ~60 minutes

Mergeability Score: 🟡 Moderate · up to 6fd73

The renderer migration can misclassify JSON, text-like, or extensionless attachments as unknown links, omit fault reporting when exported payloads cannot be read, and produce ambiguous iteration labels or unstable ordering in edge cases. These are bounded but concrete output and diagnostic correctness risks, so merge should wait for fixes or explicit owner acceptance.

Sequence Diagram(s)

sequenceDiagram
  participant Summary
  participant LegacyResultReader
  participant ParsedResult
  participant Run
  participant TestSummary
  Summary->>LegacyResultReader: read xcresult bundle
  LegacyResultReader->>ParsedResult: create parsed runs and test hierarchy
  LegacyResultReader-->>Summary: return ParsedResult
  Summary->>Run: initialize from ParsedRun
  Run->>TestSummary: build summaries from testables
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: ParsedResult, LegacyResultReader, and renderer migration for Tasks 3–5b.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tylervick/parsed-result-391

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/ResultFile.swift (1)

128-133: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Record a fault when an exported payload cannot be read.

exportPayload(reference:fileName:) records .payloadExportFailed on both the export failure and the move failure. exportPayloadData(reference:) records the fault only on the export failure. If Data(contentsOf:) throws, the method logs a warning and returns nil without recording anything. In .inline rendering mode this produces content .none for a payload that exists, which is real degradation.

Add the fault record so --lenient and the exit-3 path see the same degradation in both rendering modes.

Based on learnings: "an exported attachment that cannot be read is a genuine .payloadExportFailed fault... The method must log the read error and record the fault so --lenient can handle the degraded report."

🐛 Proposed fix
         do {
             return try Data(contentsOf: savedURL)
         } catch {
-            Logger.warning("Can't get content of \(savedURL)")
+            Logger.warning("Can't get content of \(savedURL). \(error.localizedDescription)")
+            faultCollector.record(.payloadExportFailed, "payload id \(reference)")
             return nil
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/ResultFile.swift`
around lines 128 - 133, Update exportPayloadData(reference:) to record a
.payloadExportFailed fault when Data(contentsOf:) throws, while preserving the
existing warning log and nil return. Use the same fault-recording mechanism and
reference context already used by exportPayload(reference:fileName:) for export
or move failures.

Source: Learnings

🧹 Nitpick comments (2)
Tests/XCTestHTMLReportTests/LegacyResultReaderTests.swift (1)

30-47: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a regression assertion for failure/activity ordering.

This test verifies repetition merging but not the required start-based interleave. A regression to append failure rows, or to restore finish ordering, can still pass these assertions. In the failed iteration, assert that the failure activity occurs after Retryable Activity.

[recommend_recommended_refactor]

Based on learnings: RetryResults / testRetryOnFailure() exercises failure ordering because its assertion occurs during Retryable Activity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/XCTestHTMLReportTests/LegacyResultReaderTests.swift` around lines 30 -
47, Extend testRepetitionsMergeIntoOneTestCase to inspect the failed iteration’s
activities and assert that the failure activity appears after “Retryable
Activity” in start-based order. Use the existing RetryResults fixture and
testRetryOnFailure() case, preserving the current repetition and status
assertions.

Source: Learnings

Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/ResultFile.swift (1)

170-192: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional cleanup only: simplify two non-functional code-quality issues.

  • formatEmittedOutput() returns a non-optional String, so map is clearer than compactMap in both extensions in ResultFile.swift.
  • The Iteration initializer carries repeated rendering-context parameters; consider bundling them into a RenderingContext value type as a follow-up refactor.

Neither suggestion changes behavior or blocks this migration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/ResultFile.swift`
around lines 170 - 192, Replace compactMap with map in the subsections
transformations within ActivityLogUnitTestSection.formatEmittedOutput() and
ActivityLogSection.formatEmittedOutput(), preserving the existing string
formatting and joining behavior.

Apply the same fix in
`@Sources/XCTestHTMLReportCore/Classes/Models/Iteration.swift` around lines 29 -
38: The repeated rendering-context parameter suggestion is preserved here.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift`:
- Around line 26-45: Update Attachment.init(filenameExtension:) to map text-like
extensions such as json, csv, xml, md, and plist to .text, while preserving
UTI-derived types for unsupported extensions, including public.data when the
filename is not .dat. Add regression tests covering these extension mappings and
UTI fallback behavior.

In `@Sources/XCTestHTMLReportCore/Classes/Models/Iteration.swift`:
- Line 69: Update the iteration initializer call in Test.swift to pass the
existing positional index, then change the Iteration title fallback from
iterationNumber ?? 0 to index + 1 when no backend repetition number is
available. Preserve the 1-based iterationNumber value when present and ensure
unnumbered iterations receive distinct titles.

In
`@Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/LegacyResultReader.swift`:
- Around line 211-229: Add a "public.data" case to the UTI switch in
filenameExtension(forUTI:filename:) and map it to the appropriate data-file
extension so attachments without filename extensions are no longer resolved as
unknown.
- Around line 133-135: The combined activities/failures sort in the legacy
result reader needs a deterministic positional tiebreak for equal optional
timestamps. Update the `combined` sort closure to compare the existing
source-position field after timestamp equality, reusing the positional tiebreak
pattern already present elsewhere in `LegacyResultReader`.

In `@Tests/XCTestHTMLReportTests/FaultReportingTests.swift`:
- Around line 81-95: In the test around LegacyResultReader and exportLogs,
assert that file.exportLogsData(reference: logReference) returns non-nil
immediately after obtaining logReference and before changing bundle permissions.
Keep the existing permission change and .logExportFailed assertion so the test
verifies a successful read followed specifically by a write failure.

---

Outside diff comments:
In `@Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/ResultFile.swift`:
- Around line 128-133: Update exportPayloadData(reference:) to record a
.payloadExportFailed fault when Data(contentsOf:) throws, while preserving the
existing warning log and nil return. Use the same fault-recording mechanism and
reference context already used by exportPayload(reference:fileName:) for export
or move failures.

---

Nitpick comments:
In `@Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/ResultFile.swift`:
- Around line 170-192: Replace compactMap with map in the subsections
transformations within ActivityLogUnitTestSection.formatEmittedOutput() and
ActivityLogSection.formatEmittedOutput(), preserving the existing string
formatting and joining behavior.

Apply the same fix in
`@Sources/XCTestHTMLReportCore/Classes/Models/Iteration.swift` around lines 29 -
38: The repeated rendering-context parameter suggestion is preserved here.

In `@Tests/XCTestHTMLReportTests/LegacyResultReaderTests.swift`:
- Around line 30-47: Extend testRepetitionsMergeIntoOneTestCase to inspect the
failed iteration’s activities and assert that the failure activity appears after
“Retryable Activity” in start-based order. Use the existing RetryResults fixture
and testRetryOnFailure() case, preserving the current repetition and status
assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 359271ec-4fe0-41ab-957f-efbbfab04c84

📥 Commits

Reviewing files that changed from the base of the PR and between e2a3e9c and 6fd73fc.

📒 Files selected for processing (20)
  • Sources/XCTestHTMLReportCore/Classes/Models/Activity.swift
  • Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift
  • Sources/XCTestHTMLReportCore/Classes/Models/Iteration.swift
  • Sources/XCTestHTMLReportCore/Classes/Models/JUnitReport.swift
  • Sources/XCTestHTMLReportCore/Classes/Models/Run.swift
  • Sources/XCTestHTMLReportCore/Classes/Models/RunDestination.swift
  • Sources/XCTestHTMLReportCore/Classes/Models/Summary.swift
  • Sources/XCTestHTMLReportCore/Classes/Models/TargetDevice.swift
  • Sources/XCTestHTMLReportCore/Classes/Models/Test.swift
  • Sources/XCTestHTMLReportCore/Classes/Models/TestSummary.swift
  • Sources/XCTestHTMLReportCore/Classes/Protocols/EmittableOutput.swift
  • Sources/XCTestHTMLReportCore/Classes/Protocols/TestConforming.swift
  • Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/LegacyResultReader.swift
  • Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/ResultFile.swift
  • Sources/XCTestHTMLReportCore/Classes/ResultReading/ParsedResult.swift
  • Sources/XCTestHTMLReportCore/Classes/ResultReading/ResultReader.swift
  • Tests/XCTestHTMLReportTests/FaultReportingTests.swift
  • Tests/XCTestHTMLReportTests/LegacyResultReaderTests.swift
  • docs/superpowers/plans/2026-08-10-xcresulttool-legacy-migration.md
  • docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md
💤 Files with no reviewable changes (1)
  • Sources/XCTestHTMLReportCore/Classes/Protocols/EmittableOutput.swift

Comment on lines +26 to +45
/// The port carries a filename extension rather than a UTI (see "Deciding
/// the model before the port", answer 4), so both backends type
/// attachments from the same fact. `UTType(filenameExtension:)` would be
/// the direct route but is macOS 11+ against a 10.15 floor, so the mapping
/// is an explicit table like `mimeType`'s fallback below.
init?(filenameExtension: String) {
switch filenameExtension.lowercased() {
case "png": self = .png
case "jpg", "jpeg": self = .jpeg
case "heic": self = .heic
case "gif": self = .gif
case "mp4": self = .mp4
case "txt": self = .text
case "log": self = .log
case "html", "htm": self = .html
case "zip": self = .zip
case "dat": self = .data
default: return nil
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: List attachment filenames and UTIs used in fixtures and sample app, to size the typing gap.
set -euo pipefail

# Sample app attachments: which UTIs and filenames are produced.
rg -n -C3 'XCTAttachment\(' XCTestHTMLReportSampleApp || true

# Reader UTI table and its callers.
rg -n -C5 'filenameExtension\(forUTI' Sources Tests || true

# Any other place that still types attachments from a UTI raw value.
rg -n -C3 'AttachmentType\(' Sources Tests || true

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 7385


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Attachment.swift ---'
nl -ba Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift | sed -n '1,180p'

printf '%s\n' '--- LegacyResultReader.swift ---'
nl -ba Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/LegacyResultReader.swift | sed -n '180,280p'

printf '%s\n' '--- attachment type consumers ---'
rg -n -C4 'AttachmentType|\.mimeType|HTMLTemplates\.link|attachment\.type|type:' Sources Tests

printf '%s\n' '--- UTI and filename fixtures ---'
rg -n -C3 'uniformTypeIdentifier|public\.|com\.apple\.|filename:|fileName:|payload\.json|\.csv|\.xml|\.md|\.plist|\.bin|\.dat' Tests Sources XCTestHTMLReportSampleApp

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 236


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Attachment.swift ---'
cat -n Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift | sed -n '1,180p'

printf '%s\n' '--- LegacyResultReader.swift ---'
cat -n Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/LegacyResultReader.swift | sed -n '180,280p'

printf '%s\n' '--- attachment type consumers ---'
rg -n -C4 'AttachmentType|\.mimeType|HTMLTemplates\.link|attachment\.type|type:' Sources Tests

printf '%s\n' '--- UTI and filename fixtures ---'
rg -n -C3 'uniformTypeIdentifier|public\.|com\.apple\.|filename:|fileName:|payload\.json|\.csv|\.xml|\.md|\.plist|\.bin|\.dat' Tests Sources XCTestHTMLReportSampleApp

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 50389


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift'
reader='Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/LegacyResultReader.swift'

printf '%s\n' '--- current commit ---'
git rev-parse HEAD

printf '%s\n' '--- change to Attachment.swift ---'
git diff HEAD^ -- "$file"

printf '%s\n' '--- previous Attachment.swift typing and rendering ---'
git show HEAD^:"$file" | grep -n -A35 -B8 'init(' | head -n 100

printf '%s\n' '--- previous LegacyResultReader attachment parsing ---'
git show HEAD^:"$reader" | grep -n -A55 -B12 'parseAttachment\|filenameExtension' | head -n 180

printf '%s\n' '--- focused attachment-related tests ---'
rg -n -C8 'AttachmentType|filenameExtension|plain-text|public\.data|json|csv|xml|plist|unknown|HTMLTemplates\.link' Tests/XCTestHTMLReportTests Sources/XCTestHTMLReportCore/Classes

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 50390


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift'
reader='Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/LegacyResultReader.swift'

printf '%s\n' '--- focused diff ---'
git diff --unified=20 HEAD^ HEAD -- "$file"

printf '%s\n' '--- previous Attachment.swift ---'
git show HEAD^:"$file" | sed -n '1,120p'

printf '%s\n' '--- previous LegacyResultReader.swift attachment section ---'
git show HEAD^:"$reader" | sed -n '175,250p'

printf '%s\n' '--- focused tests and model references, excluding embedded templates ---'
rg -n -C5 --glob '!**/HTMLTemplates.swift' \
  'AttachmentType|filenameExtension|plain-text|public\.data|json|csv|xml|plist|unknown|HTMLTemplates\.link' \
  Tests Sources/XCTestHTMLReportCore/Classes/Models Sources/XCTestHTMLReportCore/Classes/ResultReading

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 50389


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift'
reader='Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/LegacyResultReader.swift'

printf '%s\n' '--- repository state ---'
git status --short
git log --oneline -5 -- "$file" "$reader"

printf '%s\n' '--- historical UTI-based typing references ---'
git log --all --oneline -S 'AttachmentType(rawValue' -- "$file" "$reader"
git log --all --oneline -S 'uniformTypeIdentifier' -- "$file" "$reader"

printf '%s\n' '--- all current and reachable AttachmentType initializers ---'
git grep -n 'AttachmentType(' $(git for-each-ref --format='%(refname)' refs/heads refs/remotes 2>/dev/null) -- "$file" "$reader" 2>/dev/null || true
git grep -n 'AttachmentType(rawValue\|uniformTypeIdentifier' HEAD^ -- "$file" "$reader" 2>/dev/null || true

printf '%s\n' '--- compact focused model output ---'
cat -n "$file" | sed -n '12,46p;145,166p;260,276p'

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 5478


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re
import subprocess

current = Path("Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift").read_text()
reader = Path(
    "Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/LegacyResultReader.swift"
).read_text()
main = subprocess.check_output(
    ["git", "show", "refs/heads/main:Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift"],
    text=True,
)

# Extract the current filename-extension switch and evaluate representative inputs.
switch = re.search(
    r"switch filenameExtension\.lowercased\(\) \{(?P<body>.*?)\n\s*default: return nil",
    current,
    re.S,
)
assert switch, "current filename-extension switch not found"
mapping = {}
for extensions, member in re.findall(
    r'case\s+"([^"]+(?:"\s*,\s*"[^"]+)*)":\s*self\s*=\s*\.(\w+)',
    switch.group("body"),
):
    for ext in re.findall(r'"([^"]+)"', extensions):
        mapping[ext] = member

print("current filename mapping:", mapping)
print("json ->", mapping.get("json", "unknown"))
print("csv ->", mapping.get("csv", "unknown"))
print("xml ->", mapping.get("xml", "unknown"))
print("md ->", mapping.get("md", "unknown"))
print("plist ->", mapping.get("plist", "unknown"))
print("dat ->", mapping.get("dat", "unknown"))

# Confirm the current reader returns a filename extension before consulting its UTI.
assert re.search(
    r"if let filename = filename.*?return ext\.lowercased\(\).*?switch uti",
    reader,
    re.S,
), "reader no longer prefers filename extension"
assert mapping.get("json") is None
assert mapping.get("dat") == "data"

# Confirm the baseline used the UTI raw value and therefore preserved these types.
assert "AttachmentType(rawValue: attachment.uniformTypeIdentifier)" in main
assert 'case data = "public.data"' in main
assert 'case text = "public.plain-text"' in main

print("baseline UTI typing: public.plain-text -> text; public.data -> data")
print("current behavior: unknown filename extensions -> unknown/link template")
PY

printf '%s\n' '--- SwiftLint complexity configuration ---'
rg -n -C3 'cyclomatic_complexity|included:|excluded:' .swiftlint.yml .swiftlint.yaml Package.swift 2>/dev/null || true

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 393


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re
import subprocess

current_path = Path("Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift")
reader_path = Path(
    "Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/LegacyResultReader.swift"
)
main_path = "Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift"

current = current_path.read_text()
reader = reader_path.read_text()
main = subprocess.check_output(
    ["git", "show", f"refs/heads/main:{main_path}"],
    text=True,
)

switch = re.search(
    r"switch filenameExtension\.lowercased\(\) \{(?P<body>.*?)\n\s*default: return nil",
    current,
    re.S,
)
assert switch, "current filename-extension switch not found"

mapping = {}
for line in switch.group("body").splitlines():
    match = re.search(r'case\s+(.+?):\s*self\s*=\s*\.(\w+)', line)
    if not match:
        continue
    for ext in re.findall(r'"([^"]+)"', match.group(1)):
        mapping[ext] = match.group(2)

print("current filename mapping:", mapping)
for ext in ("json", "csv", "xml", "md", "plist", "dat"):
    print(f"{ext} ->", mapping.get(ext, "unknown"))

assert "json" not in mapping
assert "csv" not in mapping
assert "xml" not in mapping
assert "md" not in mapping
assert "plist" not in mapping
assert mapping.get("dat") == "data"

# The filename branch returns before the UTI switch.
assert re.search(
    r"if let filename = filename.*?return ext\.lowercased\(\).*?switch uti",
    reader,
    re.S,
), "reader does not prefer filename extension"

# The baseline constructed AttachmentType from the UTI raw value.
assert "AttachmentType(rawValue: attachment.uniformTypeIdentifier)" in main
assert 'case data = "public.data"' in main
assert 'case text = "public.plain-text"' in main

print("baseline UTI typing: public.plain-text -> text; public.data -> data")
print("current behavior: unsupported filename extension -> unknown/link template")
PY

printf '%s\n' '--- SwiftLint complexity configuration ---'
rg -n -C3 'cyclomatic_complexity|included:|excluded:' .swiftlint.yml .swiftlint.yaml Package.swift 2>/dev/null || true

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 1408


Preserve UTI-derived attachment types for unsupported filename extensions. payload.json with UTI public.plain-text now becomes .unknown and renders through HTMLTemplates.link; the previous implementation rendered it as .text. Add mappings for text-like extensions such as json, csv, xml, md, and plist, or retain a UTI-derived fallback. Preserve public.data when the filename is not .dat. Add regression tests.

🧰 Tools
🪛 GitHub Check: swift

[warning] 31-31:
Function should have complexity 10 or less; currently complexity is 11 (cyclomatic_complexity)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/XCTestHTMLReportCore/Classes/Models/Attachment.swift` around lines 26
- 45, Update Attachment.init(filenameExtension:) to map text-like extensions
such as json, csv, xml, md, and plist to .text, while preserving UTI-derived
types for unsupported extensions, including public.data when the filename is not
.dat. Add regression tests covering these extension mappings and UTI fallback
behavior.

Source: Linters/SAST tools

[
"UUID": uuid,
"TITLE": "Iteration \(repetitionPolicy?.iteration ?? 0)",
"TITLE": "Iteration \(iterationNumber ?? 0)",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

iterationNumber ?? 0 renders every unnumbered iteration as "Iteration 0".

Line 23 documents iterationNumber as 1-based and nil when the backend reports none. The iteration template is used only when a test case has more than one iteration. If the backend reports no repetition numbers, all rows in that group render the identical title "Iteration 0", so a reader cannot tell the repetitions apart.

Pass the positional index into the initializer and use index + 1 as the fallback. Test.swift already has the index at the call site (identifierPath.appending("iteration\(index)")), so the fix belongs there rather than in a wider default.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/XCTestHTMLReportCore/Classes/Models/Iteration.swift` at line 69,
Update the iteration initializer call in Test.swift to pass the existing
positional index, then change the Iteration title fallback from iterationNumber
?? 0 to index + 1 when no backend repetition number is available. Preserve the
1-based iterationNumber value when present and ensure unnumbered iterations
receive distinct titles.

Comment on lines +133 to +135
combined = (activities + failures).sorted {
($0.start ?? .distantPast) < ($1.start ?? .distantPast)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect XCResultKit's ActionTestFailureSummary and ActionTestActivitySummary time fields.
set -euo pipefail

fd -i -t f 'ActionTestFailureSummary*' . | while IFS= read -r f; do
  rg -n -C 3 'timestamp|lineNumber|issueType' "$f"
done

fd -i -t f 'ActionTestActivitySummary*' . | while IFS= read -r f; do
  rg -n -C 3 'start|finish' "$f"
done

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 171


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- dependency declarations ---'
rg -n -C 3 'XCResultKit|1\.2\.1' Package.swift Package.resolved .github 2>/dev/null || true

printf '%s\n' '--- relevant reader code ---'
fd -i -t f 'LegacyResultReader.swift|Run.swift|Test.swift' . -x sh -c '
  echo "--- $1"
  rg -n -C 5 "combined|enumerated|sorted|distantPast|timestamp|start" "$1"
' sh {}

printf '%s\n' '--- local XCResultKit sources or lockfiles ---'
fd -i -t f 'ActionTestFailureSummary*|ActionTestActivitySummary*|Package.resolved|Cartfile.resolved' .

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 9835


🌐 Web query:

XCResultKit 1.2.1 ActionTestFailureSummary timestamp optional ActionTestActivitySummary start Swift source

💡 Result:

In XCResultKit, the timestamp property in ActionTestFailureSummary is defined as an optional Date type (Date?) [1][2]. Regarding ActionTestActivitySummary, this object represents a hierarchical activity within a test, and its structure includes a start property, which is also a Date? type. This property represents the start time of the activity [2]. These properties are mapped from the underlying xcresult bundle schema to friendly Swift objects to facilitate access to test result data [3][4].

Citations:


Add a positional tiebreak to the failure-interleaving sort.

ActionTestFailureSummary.timestamp and ActionTestActivitySummary.start are optional. Equal timestamps, including multiple .distantPast fallbacks, can produce different orders because Swift’s sorted(by:) is not stable. Add the source-position tiebreak used elsewhere in this file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/LegacyResultReader.swift`
around lines 133 - 135, The combined activities/failures sort in the legacy
result reader needs a deterministic positional tiebreak for equal optional
timestamps. Update the `combined` sort closure to compare the existing
source-position field after timestamp equality, reusing the positional tiebreak
pattern already present elsewhere in `LegacyResultReader`.

Comment on lines +211 to +229
static func filenameExtension(forUTI uti: String?, filename: String?) -> String? {
if let filename = filename,
case let ext = (filename as NSString).pathExtension, !ext.isEmpty
{
return ext.lowercased()
}
switch uti {
case "public.png": return "png"
case "public.jpeg": return "jpeg"
case "public.heic": return "heic"
case "com.compuserve.gif": return "gif"
case "public.mpeg-4": return "mp4"
case "public.plain-text": return "txt"
case "com.apple.log": return "log"
case "public.html": return "html"
case "public.zip-archive": return "zip"
default: return nil
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Compare the reader's UTI table with AttachmentType's known types.
set -euo pipefail

fd -t f 'Attachment.swift' Sources | while IFS= read -r f; do
  rg -n -C 2 'filenameExtension|mimeType|case ' "$f"
done

fd -t f 'LegacyResultReader.swift' Sources | while IFS= read -r f; do
  rg -n 'public\.|com\.' "$f"
done

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 5459


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- AttachmentType and Attachment initialization ---'
fd -t f 'Attachment.swift' Sources | while IFS= read -r f; do
  cat -n "$f" | sed -n '1,180p'
done

echo '--- Legacy UTI mapping and attachment construction ---'
fd -t f 'LegacyResultReader.swift' Sources | while IFS= read -r f; do
  cat -n "$f" | sed -n '180,240p'
  rg -n -C 8 'filenameExtension\(forUTI|Attachment\(' "$f"
done

echo '--- Current change summary ---'
git diff --stat
git status --short

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 11460


Add public.data to the UTI mapping.

AttachmentType recognizes public.data, but filenameExtension(forUTI:filename:) does not. Attachments without a filename extension and with this UTI resolve to .unknown.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Sources/XCTestHTMLReportCore/Classes/ResultReading/Legacy/LegacyResultReader.swift`
around lines 211 - 229, Add a "public.data" case to the UTI switch in
filenameExtension(forUTI:filename:) and map it to the appropriate data-file
extension so attachments without filename extensions are no longer resolved as
unknown.

Comment on lines +81 to +95
let parsed = try XCTUnwrap(LegacyResultReader(file: file).read())
let logReference = try XCTUnwrap(
parsed.runs.first?.logReference,
"Fixture is expected to carry a log reference"
)

// Reading still works — only the write destination is unwritable.
try fileManager.setAttributes(
[.posixPermissions: 0o555], ofItemAtPath: bundle.path
)

XCTAssertNil(file.exportLogs(reference: logReference))
XCTAssertTrue(
collector.faults.contains { $0.kind == .logExportFailed },
"A log that reads but cannot be written must be recorded as degradation"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Prove that the log read succeeds before testing the write failure.

A non-nil logReference does not prove that file.getLogs(id:) succeeds. Both exportLogs failure branches return nil and record .logExportFailed. The test can therefore pass without executing the write-error path.

Assert that file.exportLogsData(reference:) is non-nil before line 88 changes permissions.

Proposed test change
         let logReference = try XCTUnwrap(
             parsed.runs.first?.logReference,
             "Fixture is expected to carry a log reference"
         )
+        XCTAssertNotNil(
+            file.exportLogsData(reference: logReference),
+            "Fixture log must be readable before testing the output write failure"
+        )
 
         // Reading still works — only the write destination is unwritable.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let parsed = try XCTUnwrap(LegacyResultReader(file: file).read())
let logReference = try XCTUnwrap(
parsed.runs.first?.logReference,
"Fixture is expected to carry a log reference"
)
// Reading still works — only the write destination is unwritable.
try fileManager.setAttributes(
[.posixPermissions: 0o555], ofItemAtPath: bundle.path
)
XCTAssertNil(file.exportLogs(reference: logReference))
XCTAssertTrue(
collector.faults.contains { $0.kind == .logExportFailed },
"A log that reads but cannot be written must be recorded as degradation"
let parsed = try XCTUnwrap(LegacyResultReader(file: file).read())
let logReference = try XCTUnwrap(
parsed.runs.first?.logReference,
"Fixture is expected to carry a log reference"
)
XCTAssertNotNil(
file.exportLogsData(reference: logReference),
"Fixture log must be readable before testing the output write failure"
)
// Reading still works — only the write destination is unwritable.
try fileManager.setAttributes(
[.posixPermissions: 0o555], ofItemAtPath: bundle.path
)
XCTAssertNil(file.exportLogs(reference: logReference))
XCTAssertTrue(
collector.faults.contains { $0.kind == .logExportFailed },
"A log that reads but cannot be written must be recorded as degradation"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/XCTestHTMLReportTests/FaultReportingTests.swift` around lines 81 - 95,
In the test around LegacyResultReader and exportLogs, assert that
file.exportLogsData(reference: logReference) returns non-nil immediately after
obtaining logReference and before changing bundle permissions. Keep the existing
permission change and .logExportFailed assertion so the test verifies a
successful read followed specifically by a write failure.

@tylervick
tylervick merged commit e66a2f7 into main Aug 12, 2026
9 checks passed
@tylervick
tylervick deleted the tylervick/parsed-result-391 branch August 12, 2026 23:48
tylervick added a commit that referenced this pull request Aug 13, 2026
…t typing (#391, Tasks 8–10) (#447)

* test: pin extension-based attachment typing and map public.data on the legacy backend

Task 10 of the migration plan. The AttachmentType(filenameExtension:)
initializer itself landed with #443; what was missing is the cross-backend
agreement pin and one table entry: LegacyResultReader's UTI fallback omitted
public.data, so an extensionless public.data attachment degraded
.data -> .unknown on the legacy backend only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: surface XCResultToolError diagnoses through LocalizedError

Carried forward from the #441 review: consumers now exist (faults and
warnings format these errors), and localizedDescription printed the generic
NSError boilerplate rather than the command, exit status, and stderr.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: export attachments through the modern xcresulttool path

Task 9 of the migration plan. One `export attachments` call per bundle plus
the manifest.json join (activity attachment uuid <-> basename of
exportedFileName) replaces the legacy per-payload export and its lock table;
only the one-shot export is guarded. Run logs come from `get log --type
action` and render from the structured messages, since the new format has
no emittedOutput.

Committed ahead of Task 8 so each commit builds: the reader's payloadStore
property references this type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: add ModernResultReader for the new xcresulttool format

Task 8 of the migration plan. Parity rules carried exactly: statuses map
into the neutral enum (Expected Failure still flattens to unknown
downstream); multi-repetition status derives from the Repetition children
and ignores the parent Test Case's own result (RetryResults reports Passed
there for a Failed-then-Passed test); the tree renders flat, without the
legacy wrapper groups.

Failure text joins the two documents rather than choosing one: measured on
Xcode 26.2, every assertion failure appears both as a failure-flagged
activity row (positioned and timestamped, but stripped of file:line) and as
a Failure Message node (file:line kept, no timestamp). Each message retitles
the first unclaimed failure activity whose title is its exact suffix, in
document order, nested rows included; unmatched messages (skip reasons,
expected-failure notes) append. The plan's original guard kept the activity
title instead, which shipped strictly less information than the spec's
failureTitlePrefix entry documents — coordinator-approved correction,
amended in the plan.

A failed activities query records the new .missingActivities fault; fields
the format structurally lacks never fault, pinned by a full modern-path
render of all three fixtures asserting zero faults.

The sample app gains a parameterized @test so Arguments nodes stop being
fixture-unexercised; its argument sets merge into one rendered row, moving
the TestResults header total from 17 to 18.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: amend plan and spec for Tasks 8-10 as executed

The load-bearing correction: failure text is a join of the two documents,
not a choice between them. Measured on Xcode 26.2, the activities document
carries every assertion failure as a positioned, timestamped, sometimes
nested failure row missing only the file:line prefix; the plan's original
anti-double-count guard would have dropped the prefixed Failure Message and
shipped less than the spec's failureTitlePrefix entry documents.
Coordinator-approved retitle-join recorded in both documents.

Mechanical notes: Task 9 commits before Task 8 (type dependency), TestRun is
nested in TestActivities, the AttachmentType initializer shipped failable in
443, and regenerating fixtures after the sample-app change collapses the
plan's 6-of-7 intermediate state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
tylervick added a commit that referenced this pull request Aug 13, 2026
…elease notes (#391, Tasks 14–15) (#451)

* feat!: emit our own schema from --json instead of the legacy object graph

BREAKING: --json previously dumped xcresulttool's legacy object graph verbatim.
That graph is Apple's internal shape and disappears with the legacy commands,
so --json now emits a documented schema, identical on both backends.

docs/json-schema.md is the contract, written before the encoder: field names
and nesting with a complete worked example, enum spellings, one uniform null
rule, duration and timestamp formats, ordering guarantees, and a semver
schemaVersion policy. The encoder (JsonReport.swift) is an explicit layer
rather than a synthesized Encodable on the internal model, so renaming a
Parsed* property breaks compilation instead of silently renaming public
output. ResultFile.exportJson() — the last exportRecursiveJson() call, kept
since Task 5a — is deleted; XCResultKit is confined to ResultReading/Legacy/.

JsonReportTests holds the output to the contract across both backends:
recursive schema identity, values deeply equal outside the two permitted
difference classes (JsonClassMask masks exactly the declared losses, with
non-vacuity stats), and arguments compared per class 2 — legacy asserted
== [] explicitly, modern asserted non-empty for the parameterized fixture.

The value differential surfaced one reader-parity gap the HTML differential
cannot see: under a retry-enabled plan, legacy stamps every summary
"iteration 1" while modern reports repetition info only for real
repetitions, and nothing renders a lone iteration number. The legacy reader
now strips it (strippingLoneIterationNumber) — a single execution carries no
repetition information on either backend. Recorded as a Task 14 execution
rule in the spec.

Refs #391 (Task 14).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: fold in the carried review findings from #447 and #450

- Pin the no-startTime keep-guard (#450 review): a no-start activity row
  carrying an attachment, a failure flag, or surviving children is kept by
  the symbol-annotation drop; only the contentless row is an annotation.
  Crafted-document test, since no fixture produces the shape.
- Suffix the failure-artifact name per matrix leg in test.yml (#450 review):
  upload-artifact v4 refuses duplicate names, so a run where both legs fail
  would have lost the second upload.
- ModernPayloadStore takes XCResultToolInvoking instead of the concrete
  client (#447 review), so export-failure faulting is provable in-suite; the
  new test also pins that a failed one-shot export no longer leaks its temp
  directory (removed in the catch — deinit never saw it).
- An out-of-range repetition index no longer falls back silently to
  runs.first (#447 review): the iteration renders no activities and records
  .missingActivities, instead of borrowing repetition 1's rows.

Refs #391.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: document --result-reader and the --json schema change

README gains the --result-reader section, the --json break with a
before/after snippet, and the known backend differences drawn from the
allow-list. The 4.0 release notes are drafted at docs/release-notes/4.0.0.md
in the 3.0.0 narrative style for the release to source, covering the full
Task 15 checklist including the items added by rulings R1/R5/R7 and the
#443/#450 reviews. The spec gains a status header (phases 1-5 landed, phase
6 deliberately deferred) and the Task 14 execution rules; the plan's Tasks
14-15 are ticked with implementation amendments recorded.

Refs #391 (Task 15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: state schema identity, not value identity, in the README

The --json section claimed the file is identical across readers; the
contract's own headline is schema identity, with declared value differences.
Point at those and at testCase.arguments, the modern-only capability.
Addresses the valid half of CodeRabbit's review on #451.

Refs #391.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai coderabbitai Bot mentioned this pull request Aug 13, 2026
3 tasks
tylervick added a commit that referenced this pull request Aug 15, 2026
…ixes #460) (#486)

* Give every status a filter, and the log something to filter it with (fixes #460)

The last of #439's redesign PRs. A1 gave the report a summary header, A2 gave
the tree Xcode's outline, A3a gave each view its own surface and reserved a
slot in each toolbar. This fills the slots.

## The expected-failure bucket (#460)

The model has named `.expectedFailure` since #443 and A1's tally has counted
it in a bucket of its own since the header landed. The filter row was the last
reading of a run that still had it in no bucket at all: five functions, each
naming the four row classes it shows or hides, and an expected failure carries
none of them. So "Passed" left one on screen — a reader asking for the tests
that passed was shown one that did not — and "All" could not put it back,
because "All" only ever set `display: block` on those same four. The group
pass then read the row as hidden, so a suite of nothing but expected failures
vanished from the tree the moment the reader touched any filter, "All"
included.

**Decision: they leave `unknown` entirely, and nothing in the header moves.**
That is the whole point — A1 already put them where they belong, and the
coordination this needed was to make the toolbar agree rather than to move a
count. `CoreTests` has carried the disagreement as a comment since A1 ("the two
readings disagree until A3 rebuilds the filters"); it now asserts the partition
instead.

**The pills are the legend, made operable.** Both are rendered from the run's
`Tally`: same buckets, same order, same drop-the-empty-ones rule. A leading
"All", then one pill per outcome the run produced. That is what closes the
class of defect rather than the instance — `unknown` had exactly the same gap
and no issue filed against it, and it is filtered now for the same reason
`expectedFailure` is: a status gets a pill *because* the run produced it.

Three consequences worth stating, since each was a filter that did not finish:

- **A suite goes when its last visible row does.** The pass this replaces
  bailed out on any group holding a sub-group, so the legacy backend's two
  wrapper levels — "Selected tests" and "<target>.xctest" — survived every
  filter. Two selected rows sat under three empty headings. Deciding
  deepest-first covers both cases with one rule.
- **A filtered-out test takes its screenshot with it.** `.screenshot-tail` is
  emitted *between* rows rather than inside one (A2), so it was outside
  everything the filters touched.
- **A childless suite is a row.** `Run.allTests` counts one as a leaf — the
  shape a crashed target leaves behind, which `TruncationFaultTests` is built
  from — so the pills count it and now filter it.

## Tests and runs

Xcode's toolbar states 21 tests and 23 runs on `TestResults.xcresult`; ours
knew only tests. The difference is `parameterizedAddition(value:)`, which ran
once per argument set.

Neither `iterations` nor `arguments` could carry that: both readers
deliberately collapse argument executions into one iteration (that is what
makes the two backends agree on the rows), and only the modern format names
the argument values. The *count* is in both bundles — legacy has one sibling
metadata entry per execution, modern one `Arguments` child — and legacy was
throwing it away in `mergingArgumentExecutions`. `ParsedTestCase` gains
`executionCount`, so this converges the readers rather than reading anything
new, and `DifferentialTests.testExecutionCountsAgreeAcrossBackends` holds them
converged, with a non-vacuity check because equality is free if nothing ever
ran twice.

The toolbar reads `23 executions`, not "23 runs": this report already spends
the word "run" on a destination, which the picker labels `Run 1`, `Run 2`.
It is `aria-live`, because a filter changes it. Each row that accounts for a
difference says so — `3 arguments`, the option-A mockup's own tag — and only
the count is stated, never the values, which one backend cannot see.

No per-run rows in the tree: counts and filter semantics only.

## A name filter, in both views

Xcode puts a Filter field at the trailing end of both toolbars. So does this,
in the slot A3a reserved. On Tests it is a name-substring filter over the tree
that *composes* with the pills — "Failed" and "one" together mean the failing
tests whose names contain "one". On Logs it filters the log's lines.

**The log moves into the page to make that possible.** It was an
`<iframe src>` pointing at a `file://` sibling or a `data:` URI — a foreign
origin either way — and two things followed that the report shipped: nothing
in the page could filter, search or scroll it, which is why A3a's toolbar
carried an inert "All Messages" label where a control belongs; and it could
not see the token layer, so in dark mode the Logs view was a white slab with
black text, the same class of defect as the base64 status PNGs #459 replaced.
`logs-1440-dark.png` in the A3a evidence is that slab; the one here is the
same view themed.

A single `<pre>` rather than an element per line: a filter that rebuilds one
text node is a string join, where a six-figure log would otherwise be a
six-figure DOM. The `.log` export is untouched — it is the artifact #480 fixed
and `DifferentialLogTests` compares — and inline mode gets *smaller*, since
escaped text costs less than the same bytes base64'd into a URI.

## Substitution order, closed rather than ordered

`Run` fills its two templates from two ordered lists instead of one
dictionary. `HTML.html` reduces over a dictionary, so it fills placeholders in
hash order and fills the ones an earlier replacement *inserted* as readily as
the ones the template author wrote — the hazard the A3a review found in the
picker. Two per-view templates make it closable: each list holds only what its
own template needs, so a test named `[[LOG_TEXT]]` is not merely filled late
but unfillable, and a log line reading `[[TEST_SUMMARIES]]` likewise. Both
directions pinned in `PlaceholderOrderTests`.

## Verification

- `swift test` — 193 tests, 0 failures, 3 skipped, on both legs (default/auto
  and `XCHR_RESULT_READER=modern`).
- `DifferentialTests` green on both legs; the allow-list is untouched.
- `visual` — 48 Playwright tests, 0 failures: six axe states (two new — the
  filtered tree and the filtered log), contrast over both themes, both
  fixtures and now both *views*, the filter behaviour, and 375px.
- Masked duration shapes untouched.
- `swiftformat --lint` clean; no new SwiftLint warnings.

Refs #439.

* Match suite names in the tree filter too

A filter over a tree that only ever reads its leaves is one a reader finds out
about the hard way: typing the name of a suite they can see emptied the pane.
Matching an ancestor keeps that suite and the tests inside it, which is what
the same query does in Xcode's outline.

The status filter stays a leaf question. A suite's own status is folded from
its children, so filtering on it would put rows of every outcome under a
heading claiming one.

Gated with its own precondition: the test asserts that no row carries the
suite's name itself, or it would be measuring substring matching rather than
ancestry.

Refs #439.

* Put the log's monospace stack in the token layer

The one literal A3b left outside it. The sheet has had zero hardcoded font
stacks since #455 gave it a token layer, and the log body had no business
being the exception.

Refs #439.

* Carry every tail screenshot with its row, not just the nearest

Review finding, verified before fixing. `TestScreenshotFlow` emits a test's
*last three* screenshots (`suffix(tailCount)`), so a test with several is
preceded by several — and the filter walked back exactly one sibling, leaving
the outer ones on screen with no row to belong to.

The synthetic fixture already contains such a row (`testFails()`, whose
standard activities and failure activity each carry a screenshot), so the gate
was passing on a one-tail row while the two-tail one went unchecked. It now
counts every tail rather than the first, and asserts the fixture actually holds
a run of more than one — mutation-tested against the one-sibling version, which
it fails.

Refs #439.

* Keep the executions count level with the rows on screen

Review finding, reproduced before fixing. Filter the real two-bundle report to
`Passed` and the toolbar reads `14 executions`; jump to a failing test from the
digest and a thirteenth row appears — 15 executions on screen — while the
toolbar still says 14. `digestJump` cleared `display` on the row and its
ancestors itself and never recomputed, so it walked around the single writer
the file's own contract names ("One writer, so the rendered value and every
recomputation of it are the same sentence"). The element is `aria-live`, so the
notice a screen-reader reader gets when a row arrives was silence.

The count is now taken off the rows rather than accumulated while the filter
decides them: `countExecutionsOnScreen` sums the visible rows' `data-runs` and
writes through `setCount`, and both the filter and the jump call it. Anything
that changes what the pane holds can restate the figure the same way.

The gate is the reviewer's scenario exactly — `Passed`, then the digest jump —
asserting the toolbar equals the executions actually visible, summed from the
rows, with a precondition that the jump really did reveal one. It fails on the
old code (`4 executions` where 5 are showing) and holds on the new. The goldens
carry only this script change.

Refs #439.

* Say only what the fixtures show about the combined shape

Two documentation nits from the review of #486.

The cross-reference named `testExecutionCountsMatchAcrossBackends`; the test is
`testExecutionCountsAgreeAcrossBackends`. A stale name in a repo that otherwise
keeps them exact is a reader sent looking for a test that does not exist.

And the note on a test that is both parameterized and repeated asserted more
than anyone here can know: "legacy counts the product, modern the larger of the
two". No fixture exercises that shape, which is the whole reason it is written
down — and modern's answer in particular turns on where the `Repetition` nodes
sit, `R` if they nest under `Arguments` rather than `max(R, A)`. The note now
says the shape is unmeasured and names the gate that would catch it, which is
what the reasoning behind the field actually supports.

Refs #439.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant