Skip to content
Closed
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
15 changes: 12 additions & 3 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
# What the paragraph was right about survives it: a path rule written for a
# directory nobody has created yet is a rule nothing holds, so a rule here arrives
# with the paths it is for, and where a fixture lives is the layout in #13.
# `tests/recorded/` is where that still applies. The directory holds no recording,
# the `-text` rule a recording needs is not here, and `tests/recorded/README.md`
# is where that is argued and where the rule lands with the first recording.
# `tests/recorded/` was that case until 2026-09-18, when the first recording
# landed and its rule joined the foot of this file; `tests/recorded/README.md` is
# where the rule is argued.

# Every path is text, and it is checked out with a line feed whatever a clone's
# own translation setting says. `text=auto` alone would leave the working tree to
Expand Down Expand Up @@ -56,3 +56,12 @@
# to be extended per fixture is a rule the fixture that needs it most is added
# without.
tests/fixtures/** -text

# The recorded responses (#109), for the same reason. An envelope holds what a
# server answered, and a byte the checkout may translate is a byte the comparison
# in #104 cannot rely on; `tests/fixture_bytes.rs` is what makes this rule fail
# loudly. The two Markdown files under the prefix are prose and stay normalised,
# which is why they come after the rule that would otherwise cover them.
tests/recorded/** -text
tests/recorded/README.md text=auto eol=lf
tests/recorded/**/RECORDED.md text=auto eol=lf
58 changes: 58 additions & 0 deletions tests/fixture_bytes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,61 @@ fn the_fixture_is_the_length_it_was_written_at() {
has been added or removed since."
);
}

/// Every recorded envelope under `tests/recorded/`, read exactly as it sits on
/// disk (#109). An envelope is JSON the recorder writes with a bare line feed,
/// so what the two tests below prove is narrower than the fixture above: that no
/// checkout and no recorder put a carriage return into one. The `-text` rule for
/// the directory is what lets that fail; under `text=auto` the byte would be
/// normalised away on the way into the index and neither test could go red.
fn recorded() -> Vec<(std::path::PathBuf, Vec<u8>)> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/recorded");
let mut set = Vec::new();
let dirs =
std::fs::read_dir(&root).unwrap_or_else(|e| panic!("cannot read {}: {e}", root.display()));
for dir in dirs
.map(|d| d.expect("a directory entry").path())
.filter(|p| p.is_dir())
{
let files = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()));
for path in files.map(|f| f.expect("a directory entry").path()) {
if path.extension().is_some_and(|x| x == "json") {
let bytes = std::fs::read(&path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
set.push((path, bytes));
}
}
}
set
}

const CARRIAGE_RETURN: u8 = 13;
const LINE_FEED: u8 = 10;

#[test]
fn a_recorded_envelope_carries_no_carriage_return() {
let set = recorded();
assert!(
!set.is_empty(),
"no envelope under tests/recorded/, so there is nothing for this test to prove"
);
for (path, bytes) in &set {
assert!(
!bytes.contains(&CARRIAGE_RETURN),
"{} carries a carriage return. The recorder or the checkout put a byte into the envelope that no scrubbed recording holds, which the -text rule for tests/recorded/ in .gitattributes exists to make visible rather than normalise away.",
path.display()
);
}
}

#[test]
fn a_recorded_envelope_ends_with_exactly_one_line_feed() {
for (path, bytes) in recorded() {
assert!(
bytes.last() == Some(&LINE_FEED) && !bytes.ends_with(&[LINE_FEED, LINE_FEED]),
"{} does not end with exactly one line feed, so some byte at its end was added or removed since it was scrubbed.",
path.display()
);
}
}
45 changes: 24 additions & 21 deletions tests/recorded/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Recorded fixtures, and how one is made

This directory holds responses recorded from a real server. It is empty of
recordings today, and the procedure below exists before the first one rather than
after it, which is the whole argument of #109: a rule added once fixtures have
landed does nothing about the ones already in the history, and the repair is a
history rewrite rather than a commit.
This directory holds responses recorded from a real server, one directory per
server version with a `RECORDED.md` beside the files. The first landed on
2026-09-18, and the procedure below existed before it rather than after it, which
is the whole argument of #109: a rule added once fixtures have landed does
nothing about the ones already in the history, and the repair is a history
rewrite rather than a commit.

Read this before recording. `.github/fixture-scrub/fixture-scrub.sh` refuses a
recording that did not follow it, and
Expand Down Expand Up @@ -58,24 +59,26 @@ that slips through arrives in exactly that noise.

5. Commit the scrubbed file only.

## What the first recording has to land with, and this directory does not carry yet
## What the first recording landed with

`.gitattributes` normalises every tracked path to a line feed, and the one
exception is `tests/fixtures/**`, where a fixture exists to prove a byte. A
response recorded off a wire is the same case and is not covered by that
exception: the head of an HTTP answer ends its lines with a carriage return and a
line feed, so the tree-wide rule rewrites a recording into something no server
sent, silently, on the way into the index.

The rule this directory needs is `tests/recorded/** -text` beside the one already
there, with `tests/recorded/README.md text=auto eol=lf` after it so this document
stays normalised. It is not in `.gitattributes` today, and the reason is the rule
this repository holds about guards rather than an oversight: nothing here has the
bytes for it to bite on, and a `-text` line over an empty directory is a guard
that cannot fail, which is what `.gitattributes` says in its own header it exists
against. So the line lands in the same change as the first recording, together
with an assertion over that recording's bytes in the shape
`tests/fixture_bytes.rs` already holds for the other directory.
exception was `tests/fixtures/**`, where a fixture exists to prove a byte. A
response recorded off a wire is the same case: the head of an HTTP answer ends
its lines with a carriage return and a line feed, so the tree-wide rule would
rewrite such a recording into something no server sent, silently, on the way
into the index.

So `tests/recorded/** -text` stands beside that rule, with the two Markdown
files under the prefix normalised after it, and it landed in the same change as
the first recording rather than before it, because a `-text` line over an empty
directory is a guard that cannot fail. The assertion that makes it bite is in
`tests/fixture_bytes.rs`: every envelope under this directory is read as bytes
and refused if it carries a carriage return or does not end in exactly one line
feed. That is narrower than the wire case, and on purpose: the envelopes here
are JSON the recorder writes with a bare line feed, so what the rule protects is
that no checkout and no recorder can put a carriage return into one without the
test going red, where under the tree-wide rule the byte would be normalised
away and the test could not fail.

Read that before recording rather than after. A recording committed under the
tree-wide rule has already lost the bytes, and the repair is a re-recording.
Expand Down
70 changes: 70 additions & 0 deletions tests/recorded/jellyfin-12.0.0/RECORDED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# One response set from a Jellyfin 12.0.0 server

Recorded on 2026-09-18 against a server I run for this purpose, `jellyfin/jellyfin:12.0.0`

Check failure on line 3 in tests/recorded/jellyfin-12.0.0/RECORDED.md

View workflow job for this annotation

GitHub Actions / Documents name paths that resolve

tests/recorded/jellyfin-12.0.0/RECORDED.md:3: names span `jellyfin/jellyfin:12.0.0`, which is not a tracked path in this tree
in a container on this machine, reached over loopback, with a library that holds nothing a
person made: three two-second black videos generated by the server's own ffmpeg, one film
under `Filme` and two episodes of one series under `Serien`, so that the item routes answer
with items rather than with empty lists. The server reported itself as:

```
GET /System/Info/Public ProductName "Jellyfin Server" Version "12.0.0"
```

## What each file holds

One file per capability of `docs/decisions/0272-the-route-next-up-is-read-from.md`, named
by the capability, in one envelope: `capability`, `method`, `path`, `status`, the three
response headers kept (`content-type`, `content-length`, `server`), the request body where
the call had one, and `body` as the parsed JSON, or `body_bytes` with `body_content_type`
where the answer was not JSON (the artwork bytes) or empty (a 204). The date header and the
server's timing header are not kept: both are facts about the moment and the machine rather
than about the interface, and a value that changes on every recording turns each
re-recording into a diff nobody can read.

| file | route | what it shows |
| --- | --- | --- |
| `server-identity` | `GET /System/Info/Public` | the unauthenticated identity, 200 |
| `quick-connect` | `GET /QuickConnect/Enabled` | `false` on a fresh server, 200 |
| `device-capabilities` | `POST /Sessions/Capabilities/Full` | 204 with no body |
| `library-query` | `GET /UserViews` | three views, one of them empty |
| `item-detail` | `GET /Items/{itemId}` | the film, in full |
| `resume-list` | `GET /UserItems/Resume` | empty before any playback, 200 |
| `item-user-data` | `GET /UserItems/{itemId}/UserData` | the film, unplayed |
| `artwork` | `GET /Items/{itemId}/Images/Primary` | the server's own extracted frame, image bytes |
| `artwork-head` | `HEAD` on the same path | the same headers with no body |
| `playback-selection` | `POST /Items/{itemId}/PlaybackInfo` | one media source, direct play |
| `playback-progress` | `POST /Sessions/Playing` | 204 |
| `played-marking` | `POST /UserPlayedItems/{itemId}` | the first episode marked, returns its user data |
| `next-up` | `GET /Shows/NextUp` | the second episode, after the first was marked |
| `item-user-data-episode` | `GET /UserItems/{itemId}/UserData` | the first episode, played |
| `sign-out` | `POST /Sessions/Logout` | 204 |
| `after-sign-out` | `GET /UserViews` with the ended session's token | 401, the shape of a refused call |

`delegated-sign-in`, `token-renewal` and `change-notification` have no request to record:
the first two exist on neither line, and the third is a WebSocket upgrade rather than a
response. `password-sign-in` is not in this set because its response carries the token,
which a recording never holds; its shape is the one `item-user-data` and the others were
read under.

## What was scrubbed, in the order `tests/recorded/README.md` gives

The token was removed from every envelope rather than rewritten, and no password was ever
in a response. The server address became `https://server.invalid`. The device identity was
synthetic from the first request. The account name appears in no response of this set.
Titles and paths were generated for this recording and name nobody.

Every server-supplied identifier, in keys as well as in values, became one of the sixteen
synthetic identifiers, and the recording holds twenty-one distinct ones, so which share a
value was a choice rather than an accident of order. Twelve name a thing: the server, the
three views, a view's parent, the film, its parent, the play session, the first episode,
the series behind its user-data key, the second episode and its season. Each of those got
its own value, in order of first appearance, so two things that differ in the recording
differ here. The other nine are digests, five entity tags, three image tags and the
display-preferences id, and they took the remaining four values, the last six of them
sharing the sixteenth. A reader of an image tag or an entity tag should therefore not take
equality of two of them as a fact about the server. A user-data `Key` is the server's own
hyphenated identifier with the episode's season and index appended, and the scrub keeps
that shape.

The check that judges all of this is `.github/fixture-scrub/fixture-scrub.sh check`, and
it was run on these files before they were committed.
13 changes: 13 additions & 0 deletions tests/recorded/jellyfin-12.0.0/after-sign-out.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"capability": "after-sign-out",
"method": "GET",
"path": "/UserViews",
"status": 401,
"headers": {
"Content-Length": "0",
"Server": "Kestrel"
},
"request_body": null,
"body_bytes": 0,
"body_content_type": ""
}
14 changes: 14 additions & 0 deletions tests/recorded/jellyfin-12.0.0/artwork-head.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"capability": "artwork-head",
"method": "HEAD",
"path": "/Items/00000000000040008000000000000005/Images/Primary",
"status": 200,
"headers": {
"Content-Length": "581",
"Content-Type": "image/jpeg",
"Server": "Kestrel"
},
"request_body": null,
"body_bytes": 0,
"body_content_type": "image/jpeg"
}
14 changes: 14 additions & 0 deletions tests/recorded/jellyfin-12.0.0/artwork.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"capability": "artwork",
"method": "GET",
"path": "/Items/00000000000040008000000000000005/Images/Primary",
"status": 200,
"headers": {
"Content-Length": "581",
"Content-Type": "image/jpeg",
"Server": "Kestrel"
},
"request_body": null,
"body_bytes": 581,
"body_content_type": "image/jpeg"
}
38 changes: 38 additions & 0 deletions tests/recorded/jellyfin-12.0.0/device-capabilities.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"capability": "device-capabilities",
"method": "POST",
"path": "/Sessions/Capabilities/Full",
"status": 204,
"headers": {
"Server": "Kestrel"
},
"request_body": {
"PlayableMediaTypes": [
"Video",
"Audio"
],
"SupportedCommands": [
"Play",
"PlayNext"
],
"SupportsMediaControl": true,
"SupportsPersistentIdentifier": true,
"DeviceProfile": {
"Name": "a synthetic device",
"MaxStreamingBitrate": 20000000,
"DirectPlayProfiles": [
{
"Container": "mp4",
"Type": "Video",
"VideoCodec": "h264",
"AudioCodec": "aac"
}
],
"TranscodingProfiles": [],
"CodecProfiles": [],
"SubtitleProfiles": []
}
},
"body_bytes": 0,
"body_content_type": ""
}
Loading
Loading