{"id":35206544,"url":"https://github.com/mattt/replay","last_synced_at":"2026-02-17T16:03:08.170Z","repository":{"id":329987317,"uuid":"1120545975","full_name":"mattt/Replay","owner":"mattt","description":"HTTP recording, playback, and stubbing for Swift, built around HAR fixtures and Swift Testing traits","archived":false,"fork":false,"pushed_at":"2026-01-08T16:54:32.000Z","size":158,"stargazers_count":208,"open_issues_count":0,"forks_count":3,"subscribers_count":3,"default_branch":"main","last_synced_at":"2026-01-10T20:20:44.329Z","etag":null,"topics":["http-archive","network-stubbing","swift-testing"],"latest_commit_sha":null,"homepage":"","language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mattt.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-12-21T12:59:19.000Z","updated_at":"2026-01-10T17:30:52.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/mattt/Replay","commit_stats":null,"previous_names":["mattt/replay"],"tags_count":7,"template":false,"template_full_name":null,"purl":"pkg:github/mattt/Replay","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mattt%2FReplay","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mattt%2FReplay/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mattt%2FReplay/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mattt%2FReplay/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mattt","download_url":"https://codeload.github.com/mattt/Replay/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mattt%2FReplay/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29549225,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-02-17T14:33:00.708Z","status":"ssl_error","status_checked_at":"2026-02-17T14:32:58.657Z","response_time":100,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["http-archive","network-stubbing","swift-testing"],"created_at":"2025-12-29T14:29:56.156Z","updated_at":"2026-02-17T16:03:08.165Z","avatar_url":"https://github.com/mattt.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Replay\n\nHTTP recording, playback, and stubbing for Swift,\nbuilt around \u003ca href=\"https://en.wikipedia.org/wiki/HAR_(file_format)\"\u003e\u003cabbr title=\"HTTP Archive\"\u003eHAR\u003c/abbr\u003e fixtures\u003c/a\u003e\nand [Swift Testing traits](https://developer.apple.com/documentation/testing/traits).\n\nInspired by Ruby's [VCR](https://github.com/vcr/vcr) and\nPython's [VCR.py](https://github.com/kevin1024/vcrpy) / [pytest-recording](https://github.com/kiwicom/pytest-recording).\n\n---\n\nAdd the `.replay` trait to a `@Test` declaration to specify a HAR file\ncontaining prerecorded HTTP responses:\n\n```swift\nimport Foundation\nimport Testing\nimport Replay\n\nstruct User: Codable {\n    let id: Int\n    let name: String\n    let email: String\n}\n\n@Test(.replay(\"fetchUser\"))\nfunc fetchUser() async throws {\n    // Replay intercepts HTTP request and returns a prerecorded response\n    let (data, _) = try await URLSession.shared.data(\n        from: URL(string: \"https://api.example.com/users/42\")!\n    )\n    let user = try JSONDecoder().decode(User.self, from: data)\n    #expect(user.id == 42)\n}\n```\n\nThe `.replay(\"fetchUser\")` trait loads responses from `Replays/fetchUser.har`.\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003efetchUser.har\u003c/code\u003e contents\u003c/summary\u003e\n\n```json\n{\n  \"log\": {\n    \"version\": \"1.2\",\n    \"creator\": {\n      \"name\": \"Replay/1.0\",\n      \"version\": \"1.0\"\n    },\n    \"entries\": [\n      {\n        \"startedDateTime\": \"2025-12-30T09:41:00.000Z\",\n        \"time\": 150,\n        \"request\": {\n          \"method\": \"GET\",\n          \"url\": \"https://api.example.com/users/42\",\n          \"httpVersion\": \"HTTP/1.1\",\n          \"cookies\": [],\n          \"headers\": [{ \"name\": \"Accept\", \"value\": \"application/json\" }],\n          \"queryString\": [],\n          \"headersSize\": -1,\n          \"bodySize\": 0\n        },\n        \"response\": {\n          \"status\": 200,\n          \"statusText\": \"OK\",\n          \"httpVersion\": \"HTTP/1.1\",\n          \"cookies\": [],\n          \"headers\": [{ \"name\": \"Content-Type\", \"value\": \"application/json\" }],\n          \"content\": {\n            \"size\": 52,\n            \"mimeType\": \"application/json\",\n            \"text\": \"{\\\"id\\\":42,\\\"name\\\":\\\"Alice\\\",\\\"email\\\":\\\"alice@example.com\\\"}\"\n          },\n          \"redirectURL\": \"\",\n          \"headersSize\": -1,\n          \"bodySize\": 52\n        },\n        \"cache\": {},\n        \"timings\": {\n          \"send\": 0,\n          \"wait\": 150,\n          \"receive\": 0\n        }\n      }\n    ]\n  }\n}\n```\n\n\u003c/details\u003e\n\nReplay can also stub responses inline:\n\n```swift\nimport Foundation\nimport Testing\nimport Replay\n\n@Test(\n    .replay(\n        stubs: [\n            .get(\n                \"https://example.com/greeting\",\n                200,\n                [\"Content-Type\": \"text/plain\"],\n                { \"Hello, world!\" }\n            )\n        ]\n    )\n)\nfunc fetchGreeting() async throws {\n    // Replay intercepts HTTP request and returns the stubbed response\n    let (data, _) = try await URLSession.shared.data(\n        from: URL(string: \"https://example.com/greeting\")!\n    )\n    #expect(String(data: data, encoding: .utf8) == \"Hello, world!\")\n}\n```\n\n## Requirements\n\n- Swift 6.1+\n- macOS 10.15+ / iOS 13+ / tvOS 13+ / watchOS 6+ / visionOS 1+ / Linux\n\n## Installation\n\n### Swift Package Manager\n\nAdd to your `Package.swift`:\n\n```swift\ndependencies: [\n    .package(url: \"https://github.com/mattt/Replay.git\", from: \"0.4.0\")\n]\n```\n\nThen add `Replay` to your **test target** dependencies:\n\n```swift\n.testTarget(\n    name: \"YourTests\",\n    dependencies: [\n        .product(name: \"Replay\", package: \"Replay\")\n    ]\n)\n```\n\n#### AsyncHTTPClient support\n\nReplay can also intercept requests made with\n[AsyncHTTPClient](https://github.com/swift-server/async-http-client).\nEnable the `AsyncHTTPClient` package trait:\n\n```swift\ndependencies: [\n    .package(\n        url: \"https://github.com/mattt/Replay.git\",\n        from: \"0.4.0\",\n        traits: [\"AsyncHTTPClient\"]\n    )\n]\n```\n\n### Xcode\n\n1. Add the package: **File → Add Packages…**\n2. Add **Replay** to your **test target**.\n\n## Getting Started\n\n### 0. Design your HTTP client to accept a session (optional)\n\nReplay can intercept `URLSession.shared` globally,\nbut accepting a `URLSession` parameter enables parallel test execution\nand is generally good practice.\n\n```swift\nimport Foundation\n\nstruct User: Identifiable, Codable {\n    let id: Int\n    let name: String\n    let email: String\n}\n\nactor ExampleAPIClient {\n    static let shared = ExampleAPIClient()\n\n    let baseURL: URL\n    let session: URLSession\n\n    init(\n        baseURL: URL = URL(string: \"https://api.example.com\")!,\n        session: URLSession = .shared\n    ) {\n        self.baseURL = baseURL\n        self.session = session\n    }\n\n    func fetchUser(id: User.ID) async throws -\u003e User {\n        let url = baseURL.appendingPathComponent(\"users/\\(id)\")\n        let (data, _) = try await session.data(from: url)\n        return try JSONDecoder().decode(User.self, from: data)\n    }\n}\n```\n\n### 1. Add a `Replays/` folder to your test target\n\nReplay loads archives named `Replays/\u003cname\u003e.har`.\n\nCreate a `Replays/` directory alongside your test files:\n\n```shell\nmkdir Tests/YourTests/Replays/\n```\n\n#### Swift Package Manager: Copy fixtures into the test bundle\n\nIn `Package.swift`, add:\n\n```swift\n.testTarget(\n    name: \"YourTests\",\n    dependencies: [\n        .product(name: \"Replay\", package: \"Replay\")\n    ],\n    resources: [\n        .copy(\"Replays\")\n    ]\n)\n```\n\nUse the `.playbackIsolated` test suite trait\nto point Replay at your package bundle:\n\n```swift\nimport Foundation\nimport Testing\nimport Replay\n\n@Suite(.playbackIsolated(replaysFrom: Bundle.module))\n```\n\n#### Xcode: Include fixtures as test resources\n\nAdd your `Replays/` folder to the test target and ensure it's included in the test bundle resources.\n\nUse the `.playbackIsolated` test suite trait\nto point Replay at your test bundle's resources:\n\n```swift\nimport Foundation\nimport Testing\nimport Replay\n\nprivate final class TestBundleToken {}\n\n@Suite(\n    .playbackIsolated(\n        replaysRootURL: Bundle(for: TestBundleToken.self)\n            .resourceURL?\n            .appendingPathComponent(\"Replays\")\n    )\n)\nstruct YourSuite { /* ... */ }\n```\n\n### 2. Write a test using `.replay(\"…\")`\n\n```swift\nimport Foundation\nimport Testing\nimport Replay\n\n@Suite(/* ... */)\nstruct YourSuite {\n    @Test(.replay(\"fetchUser\"))\n    func fetchUser() async throws {\n        let client = ExampleAPIClient.shared\n        let user = try await client.fetchUser(id: 42)\n        #expect(user.id == 42)\n    }\n}\n```\n\n### 3. Run tests\n\nThe first run fails if the HAR file doesn't exist yet—this is intentional\nto prevent accidental recording.\n\nReplay uses two environment variables to control behavior:\n\n- **`REPLAY_RECORD_MODE`** (default: `none`)\n  - `none`: never record\n  - `once`: record only if the archive is missing\n  - `rewrite`: rewrite the archive from scratch\n- **`REPLAY_PLAYBACK_MODE`** (default: `strict`)\n  - `strict`: require fixtures; fail if missing/unmatched\n  - `passthrough`: use fixtures when available; otherwise hit the network\n  - `live`: ignore fixtures and always hit the network\n\n```console\n$ swift test\n❌  Test fetchUser() recorded an issue at ExampleTests.swift\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n⚠️  No Matching Entry in Archive\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nRequest: GET https://api.example.com/users/42\nArchive: /path/to/.../Replays/fetchUser.har\n\nThis request was not found in the replay archive.\n\nOptions:\n1. Run against the live network (ignore fixtures):\n   REPLAY_PLAYBACK_MODE=live swift test --filter \u003ctest-name\u003e\n\n2. Rewrite the archive from scratch:\n   REPLAY_RECORD_MODE=rewrite swift test --filter \u003ctest-name\u003e\n\n3. Check if request details changed (URL, method, headers)\n   and update test expectations\n\n4. Inspect the archive:\n   swift package replay inspect /path/to/.../Replays/fetchUser.har\n\n```\n\n### 4. Record\n\n```bash\nREPLAY_RECORD_MODE=once swift test --filter YourSuite.fetchUser\n```\n\nThis creates `Replays/fetchUser.har`.\n\n\u003e [!TIP]\n\u003e To run tests against a live API (ignoring fixtures), use `REPLAY_PLAYBACK_MODE=live`.\n\n### 5. Re-run\n\n```console\n$ swift test\n✅  Test fetchUser() passed after 0.001 seconds.\n```\n\n### 6. Commit fixtures\n\nReplay can redact while recording using filters (recommended)\nor you can filter an existing HAR file using the plugin (see Tooling).\n\n\u003e [!WARNING]\n\u003e HAR files may contain sensitive data (cookies, auth headers, tokens, PII).\n\u003e Always review/redact before committing to source control.\n\n## Usage\n\n### Matching strategies\n\nBy default, Replay matches requests by HTTP method + full URL,\nwhich requires scheme, host, port, path, query, and fragment to match exactly.\nFor APIs with volatile query parameters (pagination cursors, timestamps, cache-busters),\nuse a looser matching strategy:\n\n```swift\n@Test(.replay(\"fetchUser\", matching: [.method, .path]))\nfunc fetchUser() async throws { /* ... */ }\n```\n\nMatchers compose with `AND` semantics;\nall must match for an entry to be selected.\n\n| Matcher         | Matches on                                           |\n| --------------- | ---------------------------------------------------- |\n| `.method`       | HTTP method (case-insensitive)                       |\n| `.url`          | Full URL string (strict)                             |\n| `.host`         | URL host                                             |\n| `.path`         | URL path                                             |\n| `.query`        | Query parameters (order-insensitive)                 |\n| `.headers([…])` | Specified header values (names are case-insensitive) |\n| `.body`         | Request body bytes                                   |\n| `.custom(…)`    | Custom `(URLRequest, URLRequest) -\u003e Bool`            |\n\n\u003e [!TIP]\n\u003e If built-in matchers don't cover your needs,\n\u003e use `.custom` to implement arbitrary matching logic.\n\n### Filters\n\nFilters strip sensitive data during recording:\n\n```swift\n@Test(\n    .replay(\n        \"fetchUser\",\n        matching: [.method, .path],\n        filters: [\n            .headers(removing: [\"Authorization\", \"Cookie\"]),\n            .queryParameters(removing: [\"token\", \"api_key\"])\n        ]\n    )\n)\nfunc fetchUser() async throws { /* ... */ }\n```\n\nFor request/response bodies, use `Filter.body(replacing:with:)` for string redaction\nor `Filter.body(decoding:transform:)` to transform decoded JSON.\n\n### Stubs\n\nFor simple cases, use inline stubs instead of HAR files:\n\n```swift\n@Test(\n    .replay(\n        stubs: [.get(\"https://example.com/greeting\", 200, [\"Content-Type\": \"text/plain\"], { \"Hello, world!\" })]\n    )\n)\nfunc fetchGreeting() async throws {\n    let (data, _) = try await URLSession.shared.data(from: URL(string: \"https://example.com/greeting\")!)\n    #expect(String(data: data, encoding: .utf8) == \"Hello, world!\")\n}\n```\n\n### Parallel test execution\n\nBy default, Replay uses global `URLProtocol` registration with serialized access\nto prevent cross-test interference.\nThis means tests using `.replay()` run one at a time,\neven when Swift Testing would otherwise run them in parallel.\n\nFor true parallel execution, use `scope: .test` to isolate each test's playback state:\n\n```swift\n@Suite(.playbackIsolated(replaysFrom: Bundle.module))\nstruct ParallelizableAPITests {\n    @Test(.replay(\"fetchUser\", matching: [.method, .path], scope: .test))\n    func fetchUser() async throws {\n        // Use Replay.session instead of URLSession.shared\n        let client = ExampleAPIClient(session: Replay.session)\n        _ = try await client.fetchUser(id: 42)\n    }\n\n    @Test(.replay(\"fetchPosts\", matching: [.method, .path], scope: .test))\n    func fetchPosts() async throws {\n        // Each test gets its own isolated playback store\n        let client = ExampleAPIClient(session: Replay.session)\n        _ = try await client.fetchPosts()\n    }\n}\n```\n\n**Key differences with `scope: .test`:**\n\n| Aspect          | `scope: .global` (default)      | `scope: .test`            |\n| --------------- | ------------------------------- | ------------------------- |\n| Execution       | Serialized (one test at a time) | Parallel                  |\n| URLSession      | Works with `URLSession.shared`  | Requires `Replay.session` |\n| State isolation | Shared global state             | Per-test isolated state   |\n\n\u003e [!IMPORTANT]\n\u003e When using `scope: .test`, you must use `Replay.session` (or `Replay.makeSession()`)\n\u003e instead of `URLSession.shared`. The test-scoped playback store is routed via a custom\n\u003e HTTP header that only `Replay.session` includes.\n\n### Multiple requests per test\n\nEach HAR file can contain multiple request/response entries.\nUse one archive per test—don't stack `.replay(...)` traits:\n\n```swift\n@Test(.replay(\"fetchUser\"), .replay(\"fetchPosts\")) // ❌ Don't do this\nfunc myTest() async throws { /* ... */ }\n```\n\nIf a test makes multiple requests,\nrecord them all into a single HAR file.\n\n### Creating HAR files from browser sessions\n\nYou can also capture traffic using browser developer tools.\nOpen the Network tab, trigger the requests, then export as HAR:\n\n- **Safari**: Right-click → Export HAR\n- **Chrome**: Click ↓ → Save all as HAR with content\n- **Firefox**: Right-click → Save All As HAR\n\n\u003e [!WARNING]\n\u003e Browser-exported HAR files often contain sensitive data (cookies, tokens, PII).\n\u003e Always review and redact before committing.\n\n### AsyncHTTPClient\n\nWhen the `AsyncHTTPClient` trait is enabled,\nReplay provides `HTTPClientProtocol` — a protocol that both\n`HTTPClient` and `ReplayHTTPClient` conform to.\nDesign your code against `some HTTPClientProtocol`\nand swap in `ReplayHTTPClient` during tests:\n\n```swift\nimport AsyncHTTPClient\nimport NIOCore\n\nactor ExampleAPIClient {\n    let httpClient: any HTTPClientProtocol\n\n    init(httpClient: any HTTPClientProtocol) {\n        self.httpClient = httpClient\n    }\n\n    func fetchUser(id: Int) async throws -\u003e User {\n        let request = HTTPClientRequest(url: \"https://api.example.com/users/\\(id)\")\n        let response = try await httpClient.execute(request, timeout: .seconds(30))\n        let body = try await response.body.collect(upTo: 1024 * 1024)\n        return try JSONDecoder().decode(User.self, from: body)\n    }\n}\n```\n\nIn tests, use `ReplayHTTPClient` with HAR files or inline stubs:\n\n```swift\nimport Testing\nimport Replay\n\n@Test(\"fetch user from stub\")\nfunc fetchUser() async throws {\n    let client = try await ReplayHTTPClient(\n        stubs: [\n            Stub(\n                .get,\n                \"https://api.example.com/users/42\",\n                status: 200,\n                headers: [\"Content-Type\": \"application/json\"],\n                body: #\"{\"id\":42,\"name\":\"Alice\"}\"#\n            )\n        ]\n    )\n\n    let api = ExampleAPIClient(httpClient: client)\n    let user = try await api.fetchUser(id: 42)\n    #expect(user.name == \"Alice\")\n}\n```\n\n`ReplayHTTPClient` also accepts a `PlaybackConfiguration`\nfor HAR-file-based playback:\n\n```swift\nlet client = try await ReplayHTTPClient(\n    configuration: PlaybackConfiguration(\n        source: .file(archiveURL),\n        playbackMode: .strict,\n        matchers: [.method, .path]\n    )\n)\n```\n\n\u003e [!NOTE]\n\u003e `AsyncHTTPClient` uses SwiftNIO for networking rather than Foundation's URL Loading System,\n\u003e so `URLProtocol`-based interception (used by `@Test(.replay(…))`) cannot intercept its traffic.\n\u003e The `HTTPClientProtocol` abstraction provides an equivalent mechanism through dependency injection.\n\n### Using Replay without Swift Testing\n\nFor XCTest or manual control, use the lower-level APIs directly:\n\n```swift\n// Playback from a HAR file\nlet config = PlaybackConfiguration(\n    source: .file(archiveURL),\n    playbackMode: .strict,  // or .passthrough, .live\n    recordMode: .none,      // or .once, .rewrite\n    matchers: [.method, .path]\n)\nlet session = try await Playback.session(configuration: config)\n\n// Record traffic\nlet captureConfig = CaptureConfiguration(destination: .file(archiveURL))\nlet recordingSession = try await Capture.session(configuration: captureConfig)\n\n// Read/write HAR files directly\nlet archive = try HAR.load(from: archiveURL)\ntry HAR.save(archive, to: outputURL)\n```\n\n## Tooling\n\nReplay includes a Swift Package Manager command plugin to help manage HAR archives.\n\n```bash\n# Check status of archives (age, orphans, etc.)\nswift package replay status\n\n# Record specific tests (runs `swift test --filter …` with `REPLAY_RECORD_MODE=once` or `rewrite`)\nswift package replay record ExampleAPITests.fetchUser\n\n# Note: The archive name and location come from your `@Test(.replay(\"…\"))`\n# configuration (or the auto-generated name),\n# not from the `--filter` string passed to the `swift test` command.\n\n# Inspect a HAR file\nswift package replay inspect Tests/YourTests/Replays/fetchUser.har\n\n# Validate a HAR file\nswift package replay validate Tests/YourTests/Replays/fetchUser.har\n\n# Filter sensitive data from an existing HAR\nswift package replay filter input.har output.har --headers Authorization --query-params token\n```\n\n\u003e [!NOTE]\n\u003e Add `--allow-writing-to-package-directory` to commands to skip confirmation step.\n\n## Troubleshooting\n\n### “Replay Archive Missing”\n\nThis is expected on first run (unless you've already created `Replays/\u003cname\u003e.har`).\nRecord intentionally for the failing test:\n\n```bash\nREPLAY_RECORD_MODE=rewrite swift test --filter \u003cyour-test-name\u003e\n```\n\n### “No Matching Entry in Archive”\n\nThis means the test made a request that didn't match any entry in the HAR.\nCommon fixes:\n\n- Use a more stable matcher set (often `.method, .path` instead of full `.url`)\n- Re-record the fixture intentionally\n- Inspect the archive to see what it contains:\n\n```bash\nswift package replay inspect path/to/archive.har\n```\n\n## License\n\nThis project is available under the MIT license.\nSee the LICENSE file for more info.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmattt%2Freplay","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmattt%2Freplay","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmattt%2Freplay/lists"}