Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions Tests/XCTestHTMLReportTests/BaselineCaptureTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// BaselineCaptureTests.swift
//
// Writes renders of every fixture to $XCHR_BASELINE_DIR.
// Fixtures are regenerated on each `prepareTestResults.sh` run, so a golden
// file cannot be checked in; capture before a refactor and again after,
// against one fixture generation, then diff the two directories.
//
// Renders are captured verbatim. Identifiers are deterministic per structural
// path (#430), so an identifier that moved is a real finding about the
// refactor, not noise to normalize away.
//

import XCTest
@testable import XCTestHTMLReportCore

final class BaselineCaptureTests: XCTestCase {
func testCaptureRawRenders() throws {
guard let dir = ProcessInfo.processInfo.environment["XCHR_BASELINE_DIR"] else {
throw XCTSkip("Set XCHR_BASELINE_DIR to capture baseline renders")
}
try FileManager.default.createDirectory(
atPath: dir, withIntermediateDirectories: true
)

let resources = ["TestResults", "SanityResults", "RetryResults"]
for resource in resources {
// Deliberately not `continue`: a skipped fixture would produce a
// partial baseline, and Task 5a's `diff -r` reports two partial
// directories as identical. Fail here instead.
let url = try XCTUnwrap(
Bundle.testBundle.url(forResource: resource, withExtension: "xcresult"),
"Fixture \(resource).xcresult is missing — run ./prepareTestResults.sh"
)
let html = Summary(
resultPaths: [url.path],
renderingMode: .linking,
downsizeImagesEnabled: false,
downsizeScaleFactor: 0.5
).generatedHtmlReport()
let path = "\(dir)/\(resource).html"
try html.write(
toFile: path, atomically: true, encoding: .utf8
)
let written = try XCTUnwrap(
FileManager.default.contents(atPath: path)
)
XCTAssertFalse(written.isEmpty, "\(resource) captured an empty baseline")
}

let captured = try FileManager.default
.contentsOfDirectory(atPath: dir)
.filter { $0.hasSuffix(".html") }
XCTAssertEqual(
Set(captured), Set(resources.map { "\($0).html" }),
"Baseline must contain exactly one file per fixture"
)
}
}
36 changes: 36 additions & 0 deletions Tests/XCTestHTMLReportTests/ReportNormalizer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// ReportNormalizer.swift
//
// Report element identifiers are a digest of each element's structural path
// through the report (see `IdentifierPath`, #411/#430). Two renders on one
// backend therefore agree byte-for-byte and need no normalization.
//
// Two *backends* do not: the modern reader's tree omits the "All tests" and
// "<bundle>.xctest" wrapper levels, so the same test case sits at a different
// path under each backend and digests differently. The cross-backend
// differential normalizes those digests away; nothing else does.
//

import Foundation

/// `IdentifierPath.identifier` is the first 128 bits of a SHA-256, lowercase hex.
/// Anchored with word boundaries so it cannot bite into a longer hex run.
private let identifierPattern: NSRegularExpression = {
guard let pattern = try? NSRegularExpression(pattern: "\\b[0-9a-f]{32}\\b") else {
preconditionFailure("The identifier pattern is a constant and must compile")
}
return pattern
}()
Comment on lines +18 to +23

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
set -e
printf '%s\n' '--- implementation ---'
cat -n Tests/XCTestHTMLReportTests/ReportNormalizer.swift | sed -n '1,120p'
printf '%s\n' '--- plan excerpt ---'
cat -n docs/superpowers/plans/2026-08-10-xcresulttool-legacy-migration.md | sed -n '175,225p'
printf '%s\n' '--- related tests and usages ---'
rg -n -C 3 'ReportNormalizer|identifierPattern|device_|[0-9a-f]\{32\}' Tests docs
printf '%s\n' '--- Swift availability ---'
command -v swift || true

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 13266


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- IdentifierPath and identifier rendering ---'
rg -n -C 5 'struct IdentifierPath|class IdentifierPath|enum IdentifierPath|IdentifierPath|device_' Classes Tests | head -n 240
printf '%s\n' '--- normalizer call sites ---'
rg -n -C 5 'normalizeIdentifiers|identifierPattern' Tests docs
printf '%s\n' '--- boundary probe ---'
python3 - <<'PY'
import re
pattern = re.compile(r"\b[0-9a-f]{32}\b")
hex_digest = "0123456789abcdef0123456789abcdef"
for value in [
    hex_digest,
    "device_" + hex_digest,
    "id=" + hex_digest,
    "x" + hex_digest,
    "_" + hex_digest,
    hex_digest + "_suffix",
    hex_digest + "0",
    "g" + hex_digest,
]:
    print(f"{value!r} -> {pattern.sub('ID', value)!r}")
PY

Repository: XCTestHTMLReport/XCTestHTMLReport

Length of output: 21795


Use hexadecimal boundaries for IdentifierPath digests\b does not match the digest in device_<digest>, so cross-backend normalization leaves rendered identifiers unchanged. Use (?<![0-9a-f])[0-9a-f]{32}(?![0-9a-f]) in both the implementation and the copied plan example.

📍 Affects 2 files
  • Tests/XCTestHTMLReportTests/ReportNormalizer.swift#L18-L23 (this comment)
  • docs/superpowers/plans/2026-08-10-xcresulttool-legacy-migration.md#L198-L208
🤖 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/ReportNormalizer.swift` around lines 18 - 23, The
identifierPattern in ReportNormalizer.swift must use hexadecimal-character
boundaries instead of \b so digests embedded in device_<digest> are matched;
replace its pattern with (?<![0-9a-f])[0-9a-f]{32}(?![0-9a-f]). Apply the same
pattern update in
docs/superpowers/plans/2026-08-10-xcresulttool-legacy-migration.md lines 198-208
for the copied plan example.


/// Replaces every `IdentifierPath` digest with the literal `ID`.
///
/// Only safe on reports rendered in `.linking` mode. Inline rendering embeds
/// base64 payloads, where a 32-character run drawn from `[0-9a-f]` is possible;
/// the differential renders `.linking` for this reason.
func normalizeIdentifiers(_ html: String) -> String {
identifierPattern.stringByReplacingMatches(
in: html,
range: NSRange(html.startIndex..., in: html),
withTemplate: "ID"
)
}
27 changes: 27 additions & 0 deletions Tests/XCTestHTMLReportTests/ReproducibilityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ final class ReproducibilityTests: XCTestCase {
Bundle.testBundle.url(forResource: "RetryResults", withExtension: "xcresult")
}

private var sanityResultsUrl: URL? {
Bundle.testBundle.url(forResource: "SanityResults", withExtension: "xcresult")
}

/// How many times each bundle is rendered. Identifier drift shows up on
/// every run, but ordering drift is sampled — a rendering that is stable
/// four runs out of five needs more than one comparison to catch.
Expand Down Expand Up @@ -154,6 +158,29 @@ final class ReproducibilityTests: XCTestCase {
)
}
}

// MARK: - Cross-backend normalizer

func testNormalizerReplacesIdentifiersAndNothingElse() {
let input = "id=3f9a1c07b25e48d1a6c3079e5b4d2f88 name=FirstSuite/testTwo()"
XCTAssertEqual(normalizeIdentifiers(input), "id=ID name=FirstSuite/testTwo()")
}

/// The digests are the only thing separating two backends' markup, so a
/// normalizer that matched nothing would make the Task 12 differential
/// compare raw identifiers and fail on every run.
func testNormalizerActuallyMatchesARenderedIdentifier() throws {
// `renderReport(arguments:)` is #430's helper: it runs the CLI out of
// process and returns `Data`. There is no in-process `render(_:)`.
let html = try XCTUnwrap(String(
bytes: renderReport(arguments: [XCTUnwrap(sanityResultsUrl).path]),
encoding: .utf8
))
XCTAssertNotEqual(
normalizeIdentifiers(html), html,
"Expected at least one IdentifierPath digest in the rendered report"
)
}
}

// MARK: - Helpers
Expand Down
65 changes: 44 additions & 21 deletions docs/superpowers/plans/2026-08-10-xcresulttool-legacy-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ would hide exactly the identifier regressions #430 exists to catch.
- Produces: `func normalizeIdentifiers(_ html: String) -> String` — replaces
every `IdentifierPath` digest with the literal `ID`. Used by Task 12 only.

- [ ] **Step 0: Confirm the prerequisite**
- [x] **Step 0: Confirm the prerequisite**

```bash
git log --oneline -1 -- Sources/XCTestHTMLReportCore/Classes/Helpers/IdentifierPath.swift
Expand All @@ -127,9 +127,20 @@ Expected: the file exists, and the suite passes. If `IdentifierPath.swift` is
absent you are on a tree without #430 — stop and rebase. Nothing downstream in
this plan is trustworthy without it.

- [ ] **Step 1: Write the failing test**
- [x] **Step 1: Write the failing test**

Append to the existing `ReproducibilityTests.swift` rather than creating it.
The second test renders `SanityResults`, and the shipped file only has URL
helpers for `TestResults` and `RetryResults` — add the missing one beside them
first:

```swift
private var sanityResultsUrl: URL? {
Bundle.testBundle.url(forResource: "SanityResults", withExtension: "xcresult")
}
```

Append to the existing `ReproducibilityTests.swift` rather than creating it:
Then the tests themselves:

```swift
func testNormalizerReplacesIdentifiersAndNothingElse() {
Expand All @@ -143,26 +154,28 @@ Append to the existing `ReproducibilityTests.swift` rather than creating it:
func testNormalizerActuallyMatchesARenderedIdentifier() throws {
// `renderReport(arguments:)` is #430's helper: it runs the CLI out of
// process and returns `Data`. There is no in-process `render(_:)`.
let html = String(
decoding: try renderReport(arguments: [try XCTUnwrap(sanityResultsUrl).path]),
as: UTF8.self
)
// `String(bytes:encoding:)` rather than `String(decoding:as:)` — the
// repo's SwiftLint config flags the non-failable conversion.
let html = try XCTUnwrap(String(
bytes: renderReport(arguments: [XCTUnwrap(sanityResultsUrl).path]),
encoding: .utf8
))
XCTAssertNotEqual(
normalizeIdentifiers(html), html,
"Expected at least one IdentifierPath digest in the rendered report"
)
}
```

- [ ] **Step 2: Run it to verify it fails**
- [x] **Step 2: Run it to verify it fails**

```bash
swift test --filter ReproducibilityTests
```

Expected: FAIL — `cannot find 'normalizeIdentifiers' in scope`.

- [ ] **Step 3: Implement the normalizer**
- [x] **Step 3: Implement the normalizer**

`Tests/XCTestHTMLReportTests/ReportNormalizer.swift`:

Expand All @@ -182,11 +195,17 @@ Expected: FAIL — `cannot find 'normalizeIdentifiers' in scope`.

import Foundation

// `IdentifierPath.identifier` is the first 128 bits of a SHA-256, lowercase hex.
// Anchored with word boundaries so it cannot bite into a longer hex run.
private let identifierPattern = try! NSRegularExpression(
pattern: "\\b[0-9a-f]{32}\\b"
)
/// `IdentifierPath.identifier` is the first 128 bits of a SHA-256, lowercase hex.
/// Anchored with word boundaries so it cannot bite into a longer hex run.
///
/// Not `try!` — the pre-commit SwiftLint gate errors on `force_try`, so the
/// constant pattern unwraps through a precondition instead.
private let identifierPattern: NSRegularExpression = {
guard let pattern = try? NSRegularExpression(pattern: "\\b[0-9a-f]{32}\\b") else {
preconditionFailure("The identifier pattern is a constant and must compile")
}
return pattern
}()

/// Replaces every `IdentifierPath` digest with the literal `ID`.
///
Expand All @@ -202,7 +221,7 @@ func normalizeIdentifiers(_ html: String) -> String {
}
```

- [ ] **Step 4: Run tests to verify they pass**
- [x] **Step 4: Run tests to verify they pass**

```bash
swift test --filter ReproducibilityTests
Expand All @@ -212,7 +231,7 @@ Expected: PASS. The pre-existing #430 tests must stay green — if
`testRenderingTheSameBundleTwiceProducesIdenticalBytes` starts failing, the
normalizer has been wired into the same-backend path by mistake.

- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**

```bash
swiftformat . && git add Tests/XCTestHTMLReportTests/ReportNormalizer.swift \
Expand Down Expand Up @@ -244,7 +263,11 @@ report success anyway. The identifiers are signal here, not noise.
skips otherwise. Used manually in Tasks 5a and 5b, then kept for any future rendering
change.

- [ ] **Step 1: Write the capture test**
- [x] **Step 1: Write the capture test**

(The test was originally named `testCaptureNormalizedRenders` — a leftover from
the pre-#430 revision that normalized. It captures raw bytes, and is named
`testCaptureRawRenders` accordingly.)

```swift
//
Expand All @@ -264,7 +287,7 @@ import XCTest
@testable import XCTestHTMLReportCore

final class BaselineCaptureTests: XCTestCase {
func testCaptureNormalizedRenders() throws {
func testCaptureRawRenders() throws {
guard let dir = ProcessInfo.processInfo.environment["XCHR_BASELINE_DIR"] else {
throw XCTSkip("Set XCHR_BASELINE_DIR to capture baseline renders")
}
Expand Down Expand Up @@ -308,15 +331,15 @@ final class BaselineCaptureTests: XCTestCase {
}
```

- [ ] **Step 2: Run it and confirm it skips by default**
- [x] **Step 2: Run it and confirm it skips by default**

```bash
swift test --filter BaselineCaptureTests
```

Expected: PASS with one skipped test.

- [ ] **Step 3: Capture the actual baseline**
- [x] **Step 3: Capture the actual baseline**

```bash
XCHR_BASELINE_DIR=/tmp/xchr-baseline swift test --filter BaselineCaptureTests
Expand All @@ -326,7 +349,7 @@ ls -la /tmp/xchr-baseline
Expected: three non-empty `.html` files. **Do not regenerate fixtures again
until Task 5a is verified** — a new fixture generation invalidates this baseline.

- [ ] **Step 4: Commit**
- [x] **Step 4: Commit**

```bash
swiftformat . && git add Tests/XCTestHTMLReportTests/BaselineCaptureTests.swift
Expand Down
Loading