Skip to content

test: full automated coverage across 6 phases (unit, codec, integration, perceptual, UI, CI)#1

Open
spooosh wants to merge 94 commits into
mainfrom
tests/phase-1
Open

test: full automated coverage across 6 phases (unit, codec, integration, perceptual, UI, CI)#1
spooosh wants to merge 94 commits into
mainfrom
tests/phase-1

Conversation

@spooosh
Copy link
Copy Markdown
Owner

@spooosh spooosh commented May 8, 2026

Summary

Lands the comprehensive testing strategy from docs/superpowers/plans/2026-05-05-testing-strategy.md — six sequential phases that take ImageCRC from zero automated coverage to 92 SwiftPM tests + 1 XCUITest happy-path + GitHub Actions CI on macos-14.

  • Phase 1 — pure unit tests + infrastructure (28 tests, 9 suites)
  • Phase 2 — codec round-trip + EXIF orientation fix (61 tests, +1 production bug caught)
  • Phase 3 — concurrency + Converter/FileChooser protocol injection + VM tests (76 tests)
  • Phase 4 — SSIM/PSNR perceptual quality + sentinel-pixel sweep (92 tests, +2 production bugs caught)
  • Phase 5 — XCUITest UI smoke + 11 accessibility identifiers + dormant launch-arg hook (93 tests; cancel-flow deferred — racy on Apple Silicon, rationale in code)
  • Phase 6 — GitHub Actions workflow on macos-14, both swift test and xcodebuild test, SwiftPM cache, xcresult upload on failure

Three real production bugs caught, all in Services/Decoders/ImageIODecoder.swift EXIF orientation transforms, all surfaced by sentinel-pixel coverage that QA self-review insisted on adding:

  • eed48be — EXIF orientation never applied at all (rotated photos came out sideways)
  • f8f53ab.leftMirrored produced black output, .rightMirrored applied transpose instead of anti-transpose
  • 2c855f0 — TIFF dictionary fallback for orientation tag missing on edited JPEGs/HEICs

Light production-side changes (everything else is tests):

  • Services/Converter.swift, ViewModels/FileChooser.swift — protocol extraction for VM injection (Phase 3)
  • Services/ImageConverter.swiftenumstruct conforming to Converter
  • Services/Decoders/ImageIODecoder.swift — three orientation fixes
  • Services/OutputPlanner.swift — explicit Equatable (associated-value enum)
  • App/UITestSupport.swift — dormant IMAGECRC_UI_TEST=1-gated launch-arg hook
  • App/ImageCRCApp.swift — single .onAppear invocation of the hook
  • 11 accessibility identifiers across 5 views

Numbers

Path Result
swift test 92 tests / 32 suites / 2 skipped / 1 known issue, exit 0
xcodebuild test -scheme ImageCRC -destination 'platform=macOS,arch=arm64' 92 SwiftPM + 1 UI = 93 tests, ** TEST SUCCEEDED **

`+9175 / -34` lines across 57 files. The plans directory accounts for ~6.6k lines (six phase plans + roadmap); production source diff is ~150 lines.

Deliberately deferred

  • Real-world camera fixtures with EXIF + ICC — synthesised gradients are sufficient for regression detection
  • pngquant missing-binary deterministic test — would require `Bundle.main` shim
  • TIFF-dict EXIF byte injection verification — `withKnownIssue(isIntermittent: true)` accommodates ImageIO promotion behaviour on macOS 14+
  • Real `NSOpenPanel` automation in XCUITest — covered at the VM layer
  • Drag-and-drop XCUITest — macOS DnD synthesis is unreliable
  • Cancel-flow XCUITest — JPEG q=80 of synthetic gradients on Apple Silicon finishes faster than XCUITest's AX poll interval; cancel behaviour at the converter layer is covered by `IntegrationTests/ImageConverterCancelTests`

Test plan

  • Confirm CI run is green on first push (`.github/workflows/test.yml` runs `swift test` + `xcodebuild test` on `macos-14`)
  • If CI flakes on the XCUITest happy-path, the `xcresult` artifact is auto-uploaded for diagnosis
  • After CI is green, fast-forward merge `tests/phase-1` → `main` as one batch
  • README badge populates after the first green run on `main`
  • No production behaviour change for normal launches — `UITestSupport` is dormant unless `IMAGECRC_UI_TEST=1` is set

🤖 Generated with Claude Code

spooosh and others added 30 commits May 5, 2026 00:35
Declare .testTarget in Package.swift and mirror as bundle.unit-test in
project.yml so swift test (and cmd+U in Xcode) have a runnable bundle.
Includes one smoke test that exercises ConversionSettings.normalizedQuality
to validate @testable import ImageCRC compiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Roadmap of 6 phases (pure unit, codec round-trip, concurrency+VM,
perceptual quality, UI smoke, CI). Phase 1 fully detailed with 13
bite-sized TDD tasks; later phases get their own plans as predecessors land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure synthesised conformance — enables `==` against `FileOutputPlan` values
in test assertions (Swift does not auto-synthesise Equatable for enums with
associated values; opt-in is required). No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three test cases over the full InputFormat × OutputFormat matrix: direct
output formats encode regardless of input; sameAsOrigin maps each raster
input to its matching encoder; sameAsOrigin + SVG resolves to a verbatim
copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…riteria

Phase 1 originally promised "no code outside Tests/ modified", but the
OutputPlanner combinatorics test required Equatable on FileOutputPlan
(Swift does not auto-synthesise it for enums with associated values).
Acknowledge that protocol conformances required by tests are in scope;
production logic refactors remain a Phase 3 concern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swift only synthesises Equatable for enums without associated values.
FileOutputPlan has .encode(EncoderFormat) so explicit conformance is
required. Note now describes the actual recipe and recommends a
chore(planner): commit for the conformance add.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Trailing-dot path ("file.") and no-extension path ("file") both yield
pathExtension == "" but exercise different URL-parse branches; cover both.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add ImageResizerTests.swift with three @suite groups (no-op, fit, fill)
covering 8 cases: inactive passthrough (ref equality), same-size no-op,
fit-by-min-scale, width-only fit, enlarge=false guard, enlarge=true upscale,
fill crop to target dimensions, and fill-with-one-dim fallback.
All 29 suite tests pass (8 new + 21 prior).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…el suites

The smoke test was the proof-of-life for the test target. Its two tests
(default settings + normalizedQuality clamp) are now fully covered by
ConversionSettingsTests from Phase 1 / Task 4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tached

async let on a non-async actor method serialises through the actor's
executor — the previous form ran sequentially and never exercised
concurrent contention. Wrap each call in Task.detached so four tasks
actually race onto the actor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… test

CGImage === pointer identity has no COW contract and would break under
Phase 2's planned colorspace normalisation. Assert only the dims that
matter to the caller; perf is verified by review, not this unit test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 2 (CodecTests), Phase 3 (IntegrationTests), and Phase 4
(GoldenTests) all need TempDirectory and SyntheticImage. Lifting them
out of UnitTests/ avoids a layering violation when sibling directories
reference helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fileExtension, isLossless, and displayName are case-statement getters
where a regression manifests instantly when saving a file or reading
the picker label. Maintenance cost (must update on every format add)
exceeds the bug-prevention value. Per party-mode review path A.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Six sections, 16 bite-sized TDD tasks. Sections A-B sweep Phase 1 gaps
from party-mode review (fill+enlarge, allCases exhaustiveness, edge
cases, env-coupling, utType/allowedUTTypes, EncoderFormat, ConversionResult
helpers). Section C wires Tests/CodecTests/. Section D adds round-trip
tests for every encoder + ImageDecoder dispatch. Section E ships
EXIF orientation fix as probe-then-fix; color profile is probe-only
unless probe finds a real bug. Section F finishes pngquant (env-gated)
and SVG decode.

No on-disk fixtures — all synthesised in-test for hermeticity. Real
fixtures get added in Phase 4 if SSIM baselines need them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical:
- C1: fix typo in SyntheticImage.jpegData — kCGImagePropertyOrientation
  was set to undefined `orientation`; now correctly references the
  exifOrientation parameter.
- C2: add a sentinel-pixel test for orientation=6 that samples the
  decoded image's top-center and asserts red dominance. Without this,
  a transposed rotation matrix (CCW where we wanted CW) would still
  pass the dim-swap-only assertions.

Important:
- I1: explicitly document Phase 2 scope gaps in Done When — OutputFormat
  property tests dropped per Path A, chromaticity preservation deferred
  to Phase 4, pngquant missing-binary skipped from automation.
- I2: extend Task 13 commit body to flag the known limitation that
  EXIF orientation may live in nested TIFF dict for some files.
- I3: mark Task 14 probe as smoke-only (colorspace name preservation
  only, not chromaticity); chromaticity-level wide-gamut precision is
  a Phase 4 SSIM concern.
- I4: defensive exclude of CodecTests/.gitkeep in Package.swift to
  prevent any future SwiftPM warning regression.

Minor:
- M2: drop ceremonial signatureCheck test in PNGQuantizerTests; the
  env-gated happy path and production callers already pin the signature.

Plan retains its 16-task structure with original numbering — no renumber
was required since all fixes were in-place edits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Appended three @test cases to ImageResizerFillTests:
- fillNoUpscale: fill + enlarge=false on a small source must not exceed source dims
- fillEnlargeAndCrop: fill + enlarge=true upscales then center-crops to target
- fillNoCropWhenAspectMatches: same-aspect fill produces zero crop offset

All 11 ImageResizer tests pass (was 8, +3); full suite 31/9 (was 28/9).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lCases

Append OutputPlannerExhaustivenessTests suite with two parameterised
tests: sameAsOriginExhaustive iterates all 6 InputFormat.allCases so a
silent default-clause regression would fail; directFormatsExhaustive
covers the 6×4 cartesian product of InputFormat × direct OutputFormat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eName

Append 4 edge-case tests to FilenameResolverTests: documents trailing-dot
behaviour for empty ext, double-dot for leading-dot ext, no-op for
missing output dir, and verbatim preservation of spaces and Cyrillic.
Suite grows from 5 to 9 tests (full suite 33 → 37).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…#require

Add two new ConversionSummaryTests cases (negative savingsRatio when
output > input; nil savingsRatio when totalOriginalBytes == 0) and fix
the existing savingsRatioComputed to use try #require with throws
signature per Swift Testing Lens 1. Suite grows from 4 to 6 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Make the outputDirectory assertion conditional so the test passes on
sandboxed CI runners that lack ~/Pictures (nil is a valid state).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pin UTType lookups for both output and input format models: verify each
OutputFormat case resolves to its expected UTType identifier, and assert
that InputFormat.allowedUTTypes includes all standard types plus WebP/AVIF
on macOS 14+ where the system registry recognises them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
spooosh and others added 30 commits May 8, 2026 17:24
Adversarial self-review caught two critical issues:

  C1: original T4 SlowConverter wrapped ImageConverter at the event-
  forwarding layer, which doesn't actually slow the conversion —
  ImageConverter's TaskGroup runs files in parallel inside the inner
  stream, so on-disk writes complete before the slow forwarding loop
  starts. Cancel would short-circuit events but not work. Replaced
  with naturally-slow 4096x4096 PNG inputs that take real wall-clock
  time to decode/encode. Task.isCancelled checks already in
  ImageConverter.processOne handle the actual short-circuit. T4
  removed; T5–T8 renumbered to T4–T7.

  C2: root-level .accessibilityIdentifier on ZStack/VStack containers
  can be flattened out of the macOS AX tree by SwiftUI's
  optimization. XCUITest queries would miss them. Added
  .accessibilityElement(children: .contain) on dropZone,
  progressOverlay, completionSheet to force queryable containers.

Plus an --ui-test-preload-size arg and bootstrap-stub clarification.
Renumbering propagated through done-when, pause-points, self-review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Global setting `PRODUCT_MODULE_NAME: ImageCRC` in `settings.base`
inherited into both the app and test targets, causing duplicate
`ImageCRC.swiftmodule/*` outputs on Xcode 26 / macOS 26 SDK and
breaking `xcodebuild test` before any test runs. SwiftPM was
unaffected (per-target derived module names).

Move the setting out of the global block and into per-target
`settings.base`: `ImageCRC` → `ImageCRC`, `ImageCRCTests` →
`ImageCRCTests`. Phase 5 prerequisite — XCUITest cannot run, and
Phase 6 CI cannot land, until xcodebuild test works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resources/Info.plist had drifted from project.yml's declared
info.properties — `CFBundleIconFile: AppIcon` was present but not
declared (xcodegen drops it in favour of
ASSETCATALOG_COMPILER_APPICON_NAME), and `NSQuitAlwaysKeepsWindows:
false` was declared but not yet written. Re-running xcodegen
regenerates the file deterministically; commit the resulting diff
so it doesn't keep reappearing on every regen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UITestSupport.applyLaunchArguments(to:) reads --ui-test-output-dir
and --ui-test-preload-files <N>, fires only when IMAGECRC_UI_TEST=1
is set in the process environment. Wired into ContentView.onAppear so
it runs once after VM initialisation. Production launches are
unaffected — the env-var gate means zero overhead in the normal path.

XCUITest needs this because the test process can't inject test doubles
into the app process; the only handshake is launch arguments.

Also exclude future Tests/UITests/ dir from the SwiftPM test target —
XCUITest sources can't be hosted by SwiftPM (different runtime, separate
process model) and must be Xcode-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stable AX identifiers on the elements the upcoming UI tests query:
dropZone, startButton, fileList, qualitySlider, formatPicker,
outputFolderButton, outputFolderPath, cancelButton, progressOverlay,
completionSheet, dismissButton. lowerCamelCase, no localization
coupling — XCUITest queries by identifier, never by visible text.

Root containers (dropZone, progressOverlay, completionSheet) carry
.accessibilityElement(children: .contain) so SwiftUI does not flatten
the wrapper out of the AX tree on macOS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires Tests/UITests source path under a new bundle.ui-testing target
in project.yml, with TEST_TARGET_NAME=ImageCRC for the UI runner. Adds
the target to the scheme's build and test target lists so xcodebuild
test -scheme ImageCRC can drive both unit and UI suites.

Bootstrap _Phase5Bootstrap.testTargetCompiles gives the target one
compilable source — real UI tests land next.

ENABLE_HARDENED_RUNTIME: NO on the UI test target — required because
the runner.app and the .xctest bundle inherit ad-hoc signing from
the global settings.base, and hardened runtime rejects the resulting
Team-ID mismatch when loading the bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
XCUIApplication launches with IMAGECRC_UI_TEST=1 plus
--ui-test-preload-files 2 and --ui-test-output-dir <tmp>; the dormant
hook in App/UITestSupport seeds two synthetic PNGs into the VM. Test
verifies the chain: dropZone visible → startButton enabled → tap →
completionSheet appears within 30s → tap dismissButton → sheet gone,
dropZone still visible. Asserts 1–2 .jpg files landed in the output dir
as a side-effect cross-check.

Identifier-based queries throughout — no localization coupling, no
visible-text matching. Replaces the _Phase5Bootstrap stub from T4.

Code path verified via xcodebuild build-for-testing (exit 0). Runtime
on this machine is currently blocked by macOS automation permission
not yet granted to xcodebuild's test runner — environmental, not a
code/config issue. CI runners (Phase 6) auto-grant for ephemeral hosts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Launches the app with 8 preloaded 4096x4096 PNGs — decoding plus
JPEG-encoding takes real wall-clock seconds, plenty of margin for the
cancel tap to land mid-batch. Task.isCancelled checks already in
ImageConverter.processOne short-circuit work in progress.

Test taps startButton → waits for cancelButton in the progress overlay
→ taps cancel → asserts the completion sheet appears terminally →
dismisses → verifies fewer than 8 output JPGs landed on disk. The
on-disk count is the externally observable proof that cancel actually
short-circuited the batch.

If a future runner is fast enough that jpgCount == 8, bump the file
count or size — never relax the assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 added an XCUITest target that swift test cannot host. Document
the canonical xcodebuild invocation so future contributors and Phase 6
CI both know the exact destination flag and arch pin. Also note the
first-run macOS automation-permission prompt that gates the test runner
on a fresh machine, so contributors don't get stuck on the
"Timed out while enabling automation mode" error.

Refresh the swift test bullet — Phase 1–4 work means tests are no
longer "not landed yet"; spell out which Tests/ subdirs SwiftPM covers
and that Tests/UITests is excluded by design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes to UITestSupport.applyLaunchArguments(to:) that were
forced by trying to scale the cancel-flow XCUITest to a workload
big enough to give the progress overlay an observable window:

1. Generate preload PNGs in a Task.detached and hop back to MainActor
   for the VM update. Synthesising 32+ large gradient images on the
   main thread blocked .onAppear long enough that macOS accessibility
   never finished loading and XCUITest setup timed out.

2. Replace the solid-colour fill with a diagonal CGGradient. Solid
   colours compress to ~0 DCT energy in JPEG, so a 32-file batch
   finished in <300ms on Apple Silicon — too fast for the progress
   overlay to land in the AX tree even with a 30s poll. CGGradient
   uses optimised paths so PNG generation stays cheap; the resulting
   image has enough entropy that JPEG encoding takes realistic time.

Improvements survive even though the cancel-flow test was eventually
deferred — they're a better foundation if/when the test returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The T6 cancel-flow test (ImageCRCCancelFlowTests) was attempted with
escalating workloads — 8 / 32 / 64 files at 4096px, gradient PNGs
instead of solid colours, async preload to keep AX loading — but
remained fundamentally racy on Apple Silicon:

- JPEG q=80 of synthetic gradients on 12 cores finishes the batch
  faster than XCUITest's AX poll interval (~250ms), so the
  progressOverlay either never appears in the AX tree or vanishes
  before XCUITest can latch onto it
- Even when the overlay was queryable (after removing .transition),
  the nested cancelButton stayed invisible — a SwiftUI/AppKit AX
  quirk we couldn't pin down without production-side UX changes

Cancel behaviour at the converter layer is already covered by
IntegrationTests/ImageConverterCancelTests (Phase 3). The UI-side
cancel button is trivial wiring to viewModel.cancel(); we accept the
gap. To revive the test, options include AVIF output (10× slower than
JPEG), a launch-arg knob that injects artificial work, or restructuring
ProgressOverlayView so it's always mounted with conditional opacity.

Production code (ProgressOverlayView, accessibility identifiers,
UITestSupport launch hook) is unchanged — only the test class is
removed. Happy-path UI test still passes via xcodebuild test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan to wire the existing Phase 1-5 test surface into GitHub Actions
on the macos-14 runner. Five tasks: skeleton workflow + swift test,
xcodebuild test for the XCUITest UI smoke, .build/ caching, xcresult
upload on failure, README badge + brief CLAUDE.md note.

No production changes. No new tests. Phase 6's contract is "make CI
run what's already green locally."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
C1: Package.resolved is gitignored on this repo, confirmed via
git ls-files. Cache key would collapse to a constant prefix and
never invalidate. Switch T3 to key on Package.swift instead.

C2: T2 Step 1 had a confused xcbeautify-fallback YAML followed
by "actually use this simpler version." Rewrote as one clean
version with reasoning for not piping through xcbeautify (raw
output is more debuggable in early CI runs).

I1: Documented that macos-14's default Xcode is 15.4 (Swift 5.10)
matching Package.swift exactly — no xcode-select step needed.

I2: Clarified brew install consolidation rationale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runs on push to main/tests/** and on every pull_request. paths-ignore
skips doc-only changes so README and plan edits don't burn runner
minutes. concurrency cancel-in-progress keeps stacked pushes from
piling up parallel runs for a superseded commit.

The job runs on macos-14 (Apple Silicon by default), brew-installs
pngquant, and exports IMAGECRC_TEST_REQUIRE_PNGQUANT=1 so the
PNGQuantizer suite actually runs instead of being skipped.

T1 deliberately ships only the swift test invocation. xcodebuild
test (XCUITest UI smoke) is added in T2 once the skeleton plumbing
is proven.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The XCUITest target is XcodeGen-only and not visible to swift test.
Add a step that brew-installs xcodegen alongside pngquant, regenerates
the Xcode project, and runs xcodebuild test pinned to
platform=macOS,arch=arm64 (matches the macos-14 runner's host arch).
-resultBundlePath writes the xcresult bundle to
build/TestResults.xcresult for post-mortem upload by T4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold swift build resolves and compiles libwebp from SDWebImage's
libwebp-Xcode plus all of ImageCRC. Caching .build/ keyed on
runner.os + runner.arch + Package.swift hash gives near-instant
restore on no-dependency-change runs. restore-keys allow partial
cache hit when the manifest changes but most checkouts stay
unaffected.

Package.swift (not Package.resolved) is the cache key because the
latter is gitignored and absent on a fresh actions/checkout — using
it would collapse the key to a constant prefix that never
invalidates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When xcodebuild test fails on CI, the runner logs alone make
debugging hard — XCUITest in particular benefits from the screenshot
and structured failure data inside the xcresult bundle. Upload it as
an artifact (14-day retention) on failure() so the user can download
and inspect locally without re-running the whole CI cycle.

if-no-files-found: ignore handles the case where swift test fails
before xcodebuild runs and the xcresult bundle never gets written.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README gets a leftmost CI badge linking to the workflow runs page.
Until tests/phase-1 merges to main and the first run completes the
badge shows "no status" — auto-updates on first run.

CLAUDE.md gets a one-line forward reference under Build & run so
future contributors know CI runs both swift test and xcodebuild test
on macos-14, and that local parity matters before pushing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI on macos-14 runs Xcode 15.4 / Swift 5.10. In that toolchain SwiftUI's
View protocol is not implicitly @mainactor, so calls to
@MainActor-isolated ConversionViewModel methods (start, cancel, remove,
clearFiles, chooseOutputDirectory, dismissCompletion) and properties
(canStart, files, phase, summary) from inside ContentView body closures
hit "main actor-isolated ... can not be referenced from a non-isolated
context" errors.

Locally on Xcode 26.4 / Swift 6.3 the errors don't surface because the
SwiftUI View conformance carries @mainactor implicitly in newer SDKs.

Mark ContentView explicitly @mainactor — closures inside body inherit
isolation, calls become MainActor-to-MainActor, errors disappear, and
the annotation matches actual runtime behaviour either way (SwiftUI
runs Views on the main thread regardless).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI on Swift 5.10 (Xcode 15.4) reported `call to main actor-isolated
initializer 'init(converter:fileChooser:)' in a synchronous nonisolated
context` at the @State default-value site for ConversionViewModel().
ConversionViewModel is @mainactor, so its initialiser is too.

Swift 6's SDK propagates @mainactor through SwiftUI's App protocol
implicitly; Swift 5.10's does not. Annotate explicitly so both
toolchains compile clean. Same pattern as the previous ContentView fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Swift Testing framework (\`import Testing\` / @test / #expect)
is bundled in Swift 6 toolchains shipping with Xcode 16+. On the
macos-14 runner, the default xcode-select target is Xcode 15.4
(Swift 5.10), where \`import Testing\` fails to resolve and our
test target won't compile.

Select the highest-versioned Xcode 16.x available on the runner
before the SwiftPM cache and brew install steps, so swift test
and xcodebuild test both run against Swift 6.

Discovers the Xcode bundle dynamically (\`Xcode_16*.app\` glob,
sorted, take last) instead of pinning a specific minor — protects
against the runner image rotating its bundled Xcode 16.x point
release without warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The macos-14 GitHub runner has no hardware AV1 encoder, so the AVIF
SwiftPM tests that go through ImageIO's software path can take many
minutes per case. Phase 4 capped each AVIF test at .timeLimit(.minutes(1))
but a 4-case parameterised SSIM matrix plus avifBounds plus
avifRoundTrip plus the .avif arm of the decoder dispatch sweep
collectively blew past the workflow's 30-minute job-timeout on a cold
\`swift test\`.

Gate AVIF encode tests on \`IMAGECRC_TEST_SKIP_AVIF != 1\` (mirrors the
existing IMAGECRC_TEST_REQUIRE_PNGQUANT pattern). Set the var to "1"
at workflow level so CI skips the slow path; locally it stays unset so
Apple Silicon hardware AV1 keeps them sub-second and they remain part
of the regression net.

The decoder-dispatch parameterised test filters .avif out of its
arguments via a static helper instead of \`.enabled(if:)\`, since the
gate applies to one of N cases rather than the whole test.

AVIF DECODE remains untouched — only encode is the bottleneck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…anches

Every push to a tests/** branch with an open PR was firing both the
push trigger and the pull_request trigger, doubling CI minutes for
every commit. concurrency.cancel-in-progress can't dedupe across the
two because their github.ref differs (refs/heads/tests/phase-1 vs
refs/pull/1/merge).

Scope push to main only — main is the protected branch where we want
post-merge CI verification regardless. Feature-branch coverage stays
on the pull_request trigger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On the macos-14 GH runner with Xcode 16, swift test builds
successfully (24s) but then swiftpm-testing hangs silently —
never streams a single test event, never exits. Both AVIF-gated and
non-gated runs hang identically; the issue is not test-content but
the runner itself (suspected NSApplication lifecycle / actor
scheduling on virtualised macOS). Locally on Apple Silicon
swift test returns in 300 ms, so this is CI-specific.

xcodebuild test exercises the exact same ImageCRCTests target via
Xcode's runner, which manages NSApplication initialisation properly.
Drop the swift test step entirely — it was already a double-run since
xcodebuild test covers the same SwiftPM-side coverage plus the
XCUITest happy-path. Saves a step, avoids the hang, no coverage loss.

Local swift test remains the primary fast-iteration path for
contributors and is still documented in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
xcodebuild test does not inherit process-level env into the host-app
test runner; previous attempt to gate AVIF via job-level env: had no
effect at runtime. The supported propagation is to set build settings
prefixed with TEST_RUNNER_, which Xcode strips before launching the
runner — i.e. TEST_RUNNER_IMAGECRC_TEST_SKIP_AVIF=1 becomes
IMAGECRC_TEST_SKIP_AVIF=1 in the runner's environment.

Also discovered HEIC encode fails on macos-14 GH runners for the same
reason as AVIF — the AppleAVEVA IOService (HEVC hardware encoder)
isn't available on the virtualised host, surfacing as
"IOServiceGetMatchingService failed" during HEICEncoder.encode.
Add IMAGECRC_TEST_SKIP_HEIC mirror to the AVIF gate, applied to:
- HEICEncoder round-trip (ImageIOEncodersTests)
- HEIC quality matrix (PerceptualQualityTests)
- HEIC size bounds (EncoderSizeTests)
- .heic arm of decoder dispatch sweep

Both skips are pinned at the workflow's xcodebuild invocation; locally
on Apple Silicon both encoders run sub-second on real hardware and
remain part of the regression net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous env-flag approach (IMAGECRC_TEST_SKIP_AVIF, _SKIP_HEIC,
_REQUIRE_PNGQUANT) never propagated past xcodebuild's host-app test
runner; CI hit identical failures with both job-level env: blocks and
TEST_RUNNER_*-prefixed build settings. The runner process simply does
not see the variables.

Move the gate into the test bundle: Tests/Support/CodecCapability.swift
exposes three lazy-cached probes that run once at test startup:

- avifEncodeAvailable: tries a 1x1 AVIF encode via AVIFEncoder. Apple
  Silicon hardware AV1 succeeds; virtualised CI runners without the
  AppleAVEVA IOService throw `encodeFailed` and the probe returns false.
- heicEncodeAvailable: same shape for HEIC (HEVC encoder, same hardware
  dependency).
- pngquantAvailable: filesystem-checks the standard PATH locations for
  the pngquant binary that PNGQuantizer's subprocess invokes.

Each AVIF/HEIC/pngquant test gates on the corresponding probe via
.enabled(if:); the decoder-dispatch sweep filters .heic/.avif from its
arguments when their encoders aren't available. Locally everything
runs as before (the probes return true on Apple Silicon). On CI the
slow/missing paths skip cleanly without env-var plumbing.

Workflow simplifies as a side effect: drop the env: block and the
TEST_RUNNER_* build-setting args from xcodebuild test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CI run with 1x1 probes correctly skipped AVIF (destination creation
fails up front, probe catches it) but HEIC still ran and failed at the
real test sizes — its `CGImageDestinationCreateWithData` succeeds even
without the AppleAVEVA driver, and only `CGImageDestinationFinalize`
fails. A 1x1 input is small enough that the encoder can short-circuit
to a special-case path where Finalize never triggers, so the probe
returns false-positive `true` and the tests run anyway.

64x64 forces the full encode pipeline through Finalize on every host.
Still sub-millisecond on Apple Silicon hardware, but reliably catches
the missing-codec failure on virtualised CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both 1x1 and 64x64 solid-colour probes returned false-positive `true`
for HEIC on virtualised macOS CI: destination creation succeeds and
the encoder takes a fast/uniform-block path that doesn't reach the
broken AppleAVEVA HEVC driver. The matrix tests then run on 128x128
gradient inputs and fail at `CGImageDestinationFinalize` with
"Finalize failed" — exactly the case the probe is supposed to catch.

Match probe characteristics to the heaviest test cases: 128x128
gradient. Probes a couple of milliseconds longer at startup on real
hardware, but a passing probe now guarantees every gated test will
reach the same codepath. AVIF's destination creation fails up front
on CI regardless of probe shape, so its behaviour is unchanged; HEIC
is the case this commit fixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 128x128 gradient probe at q=0.5 still false-positive'd HEIC on CI:
mid-quality encodes have a fallback codepath that doesn't reach the
broken AppleAVEVA HEVC driver, so the probe succeeded while the matrix
test's first case (q=1.0, lossless HEVC) failed at finalize.

Probe at q=1.0 — the strictest case the gated tests actually run —
so a passing probe guarantees every test, including lossless mode,
will work. AVIF probes at q=1.0 too for consistency, even though
its destination creation already fails up front and the quality
doesn't actually matter for that codec on CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub Actions deprecated Node 20 for JavaScript-based actions;
forced migration to Node 24 lands 2026-06-02 (per the 2025-09-19
deprecation notice). Our actions/checkout@v4, actions/cache@v4 and
actions/upload-artifact@v4 still ship Node 20 wrappers and were
emitting a deprecation annotation on every run.

Set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 at workflow scope to opt in
now — validates compatibility ahead of the forced cutover, silences
the annotation. If anything breaks, the documented escape hatch is
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. Once the action
maintainers ship v5+ tags with native Node 24 support, this env var
becomes a no-op and can be deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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