Skip to content

ADR: The five-minute gate

ADR: The five-minute gate

Status
accepted
Date
2026-09-09

The whole test wall, every suite on both stores, runs in under five minutes on the machine that ships, and the numbers that say so are in this record. Parent: ADR: The tests, explained for a new developer.

In plain words

The full test run, every suite on both databases, was cut from about twenty minutes to under five on the machine that ships. Test applications now boot a thousand vehicles instead of a hundred thousand, and the gate runs each suite once per store with its two sides at the same time.

What that is worth: a developer gets a verdict on every change in minutes with nothing skipped, and the organization ships more often without testing less.

Context

Steve asked for the tests in total to run in no more than five minutes. The ship gate at 1.0.0.96 took about twenty minutes: xUnit on SQLite, the six live store tests, the whole xUnit suite booted on Cosmos DB, the browser suite twice on SQLite and once on Cosmos DB, and in front of them prettier, lint, tsc, dotnet format, the SQL project, vitest and the em dash scan, one after another.

Where the time went was measured before anything was changed, from the test runner's own per-test timings, on the machine that ships (four cores, eight threads, eight gigabytes with about two free):

  • xUnit on SQLite, 74 seconds of wall clock for 423 seconds of test time, and the first test of every class took twelve to twenty seconds. That first test was paying for the class's application to boot, and a boot expanded the two hundred seed vehicles to a hundred thousand and built the search index over them, the site's shape and not the test's need. Nineteen class fixtures did that, and the classes that boot a host per test (AuthTests thirteen times, PersistenceTests twelve, PeerTests seven) did it again for each. The same expansion, held in every one of those processes at once, is what turned a two-minute suite into a thirty-minute crawl the night before (ADR: One container, both stores, "What it costs").
  • xUnit booted on Cosmos DB, 154 seconds for 681 seconds of test time, the same boots plus a round trip to West US 2 for every store operation.
  • The browser suite, 114 seconds for 311 seconds of test time on four workers, whose ideal is 78. The accessibility scans were one file of nine tests that ran one after another on one worker, 91 seconds, which set the length of the whole run.

Decision

A test application boots a thousand vehicles, and the one class that is about the size asks for the hundred thousand by name. A module initializer in the test assembly sets the catalogue size before any host is built, the same setting the container reads, so a developer who sets it themselves still wins; ApiIntegrationTests, whose subject is the synthetic dataset, opts back in through a FullCatalogue fixture. A thousand is enough for every other test because the schedule spreads a thousand auctions across a week: there are live, upcoming and ended vehicles on every page and every path the tests walk is the same path. A test holds the number, so a change to how the host reads its configuration cannot put a hundred thousand vehicles back into every test application without that test failing.

/// <summary>
/// The catalogue every test application boots with (ADR: The five-minute
/// gate).
///
/// <para>The site expands its two hundred seed vehicles to a hundred thousand
/// in memory at startup, and until this file existed so did every application
/// the suite booted: nineteen class fixtures and a few dozen per-test hosts,
/// each expanding a hundred thousand vehicles, building the search index over
/// them and holding all of it for the life of the class. Measured, the first
/// test of every class took twelve to twenty seconds, which was the boot, and
/// the suite's memory was the reason a two-minute run once became a
/// thirty-minute crawl.</para>
///
/// <para>A thousand is enough for every test that is not about the size: the
/// schedule spreads a thousand auctions across a week, so there are live,
/// upcoming and ended vehicles on every page, and every path the tests walk
/// is the same path. The one class that is about the size opts back in
/// through <see cref="FullCatalogue"/>. A module initializer rather than a
/// fixture, because it has to run before any host is built and the plain
/// <c>WebApplicationFactory&lt;Program&gt;</c> a class fixture hands out has
/// nowhere to say so; the setting is read by the same configuration the
/// container reads, so a developer who sets the variable themselves wins.</para>
/// </summary>
internal static class TestCatalogue
{
    public const int Vehicles = 1_000;

    [ModuleInitializer]
    internal static void Shrink()
    {
        if (Environment.GetEnvironmentVariable("Inventory__TargetCount") is null)
        {
            Environment.SetEnvironmentVariable("Inventory__TargetCount", Vehicles.ToString());
        }
    }
}

/// <summary>The site's own catalogue, a hundred thousand vehicles, for the tests that are about its size.</summary>
public sealed class FullCatalogue : WebApplicationFactory<Program>
{
    public const int Vehicles = 100_000;

    protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) =>
        builder.UseSetting("Inventory:TargetCount", Vehicles.ToString());
}

/// <summary>Holds that a test application boots the small test catalogue and the full catalogue is only built when asked for by name.</summary>
public class TestCatalogueTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    /// <summary>
    /// The number the whole suite's speed rests on, held here so a future
    /// change to how the host reads its configuration cannot quietly put a
    /// hundred thousand vehicles back into every test application.
    /// </summary>
    [Fact]
    public async Task A_test_application_boots_a_thousand_vehicles_and_the_full_catalogue_is_asked_for_by_name()
    {
        var client = factory.CreateClient();
        var page = await client.GetFromJsonAsync<JsonElement>("/api/vehicles?limit=1");
        Assert.Equal(TestCatalogue.Vehicles, page.GetProperty("total").GetInt32());

        // The schedule still puts every state on the page: the tests that walk
        // a live auction, and the ones that look for an ended or an upcoming
        // one, all find what they came for in a thousand.
        foreach (string status in new[] { "live", "upcoming", "ended" })
        {
            var some = await client.GetFromJsonAsync<JsonElement>($"/api/vehicles?status={status}&limit=1");
            Assert.InRange(some.GetProperty("total").GetInt32(), 1, TestCatalogue.Vehicles - 1);
        }
    }
}

Live from api/TheYard.Tests/TestCatalogue.cs, region test-catalogue, read from this build at 565827b.

The accessibility scans run in parallel. Each scan is its own page and its own sign-in, so the file is declared parallel and its nine tests go to whichever worker is free. On this machine that changes the shape of the run more than its length, because four Chrome workers on four cores are the floor; on a wider machine it is the difference.

The account tests are three classes over one base. xUnit runs a class's tests one after another and the classes beside each other, and the account tests boot a host each, so as one class they were the suite's longest single line on the document store: eighty-six seconds while the rest of the suite finished around them. Sign-in, lockout and bids are now three classes with the same tests under the same names, and the longest line is thirty.

The browser suite's server starts prebuilt on the gate. npm run api builds the API before it runs it, which is right on a developer's machine and ten seconds twice over on a gate that built it a minute earlier; YARD_API_PREBUILT says so and the server starts with --no-build.

The gate runs each suite once per store, and runs the two sides at the same time. One build, then two jobs side by side: the node side (prettier, lint, tsc, vitest, the browser suite on SQLite, then the store specs on Cosmos DB) and the .NET side (dotnet format, the SQL project, xUnit on SQLite, the six live store tests, xUnit booted on Cosmos DB). The browser suite used to run twice on SQLite as a check against its own flakiness. In forty-six ships the second run disagreed with a green first run once, on 1.0.0.92, and that once was a real race in the page that a single run catches on some ships and not others (ADR: Accounts and per-user bids, addendum); the answer to that class of defect is the test written for it, not a second run of every test on every ship, so the second run is gone and its two minutes with it. On Cosmos DB the browser suite runs the three spec files whose behaviour depends on the store: store-toggle (the switch, with two stores), account (registration and sign-in against the document store's accounts) and admin (the store log's card). The other ten spec files are about documents, layout, the keyboard, accessibility, the room and the page's own state, none of which changes with the store; each of them runs on SQLite in the same gate, and the whole xUnit suite runs on both stores, which is where a bid's persistence on the document store is proven. The six live store tests keep their own step, before the suite boots on the same test containers: one of them empties a container and watches it be reseeded, and a suite booting beside it reseeds it first.

The numbers

Measured on the same machine, the same day, before and after:

Suite Before After
xUnit on SQLite (373 tests) 74 s 16 s
The six live store tests 44 s 44 s
xUnit booted on Cosmos DB (373 tests) 154 s 62 s
Browser suite on SQLite (58 tests) 114 s, twice 120 s, once
Browser suite on Cosmos DB 126 s, all 58 60 s, the 12 that depend on the store
vitest (72 tests) 3 s 3 s

Each suite alone, one after another, on an otherwise idle machine. The suites in a row went from about ten minutes to under five; side by side in the gate, with the build and the checks around them, they contend for the same four cores and each takes longer than it does alone, which is the number the record's last section carries.

What did not change

Nothing is skipped and nothing asserts less. The BrokenWindows gate still forbids a skipped test. CI runs the same suites it ran before, on the same runners, and gets faster for the same reason. The production catalogue is a hundred thousand vehicles, as it was; the browser suite's API boots the site's own shape and its smoke test still counts to a hundred thousand.

Consequences

  • A test that needs more than a thousand vehicles says so through FullCatalogue, which is one line and a reason.
  • A new browser spec that reads or writes a store is added to the Cosmos DB list in the gate script, or it only ever runs on SQLite; the list is short enough to read.
  • The gate's own log times every step, so the next slow test is a number before it is a feeling.

The gate, measured

The gate's own log, the run before the one that shipped this record (queue script 690, 2026-09-08 22:37 CDT), every step timed: preview card 4 s, build 7 s; the node side prettier 7 s, lint 1 s, tsc 2 s, vitest 2 s, the browser suite on SQLite 142 s (58 passed), the three store specs on Cosmos DB 62 s (12 passed); the .NET side dotnet format 40 s, the SQL project 20 s, xUnit on SQLite 79 s (373 passed), the six live store tests 46 s, xUnit on Cosmos DB 67 s (373 passed); the em dash scan over 324 files 2 s. The whole gate: 275 seconds, everything green. The xUnit suite that takes sixteen seconds alone took seventy-nine beside the browser suite, which is the price of the two sides sharing four cores and still the cheaper shape: the sides in a row would be about six minutes.

Where it sits

No ring: this is a process decision about the test gate and the test assembly beside the onion, so the rings and SOLID do not apply to it.

Files

Addendum, 2026-09-09: the gate on a loaded machine, and the scans declared slow

The morning after this record, the gate ran seven times for four versions and went red three times on the browser suite alone, with every xUnit run green each time: a sixty-second timeout on the phone scan (queue script 715, the gate at 452 s), a Chrome session closed under the practices spec (717, 493 s), and a sixty-second timeout inside axe on the open records index (724, 373 s). The machine was measured before each retake rather than blamed: 8,040 MB in total, 1,100 MB free at 09:00 with Chrome at 3,277 MB across 51 processes, 1,793 MB free at 09:11 with Chrome at 1,898 MB, 1,219 MB free at 10:03 with Chrome at 2,080 MB across 45, read a minute before the take of 1.0.0.103 that went green at 337 s with the scans declared slow. The gate's two sides share those four cores and that memory with the browser the developer is working in, and the two scans that failed are the two that read the most nodes.

Two of the three are one defect and it is in the budget, not the scan: the accessibility scans measurably take more than sixty seconds on this machine under the gate's parallel load, and a budget that a test overruns while doing its work correctly is a wrong number. The describe now calls test.slow(), which triples the budget for those nine tests and nothing else; the scans assert exactly what they asserted, zero violations, and a scan that hangs still fails. The third, a closed session, is memory, and the lever for it is outside the repository: fewer Chrome windows while a gate runs, or a wider machine. It is logged in the gate's own numbers so the next one is a pattern and not a surprise.

The five minutes stands as the target. A green gate on this machine with the developer's browser open measured 302 s and 342 s today, and 372 s on the first run after a restart with cold caches; the suites themselves fit, and the wall clock is the machine's.

Addendum, 2026-09-14: the five-second expect

Three takes of 1.0.0.127 on 2026-09-14 (queue scripts 840, 844 and 849) went red on six different browser tests and on nothing else. Every xUnit pass was green all three times, and each failure was Playwright's default five-second expect waiting on a page element that follows a round trip costing a password hash or a store read: a refused sign-in, a reset, a bid, the forgot note, and the visitor table twice, on SQLite and on Cosmos DB, under two different tests. No test failed twice. The machine was measured before it was blamed. The first take shared the four cores with a second runner (a terminal and a scheduled task had each started one, and they ran the prep script and the gate side by side). The second ran under the scheduled task's runner at BelowNormal, the default priority Windows gives a scheduled task, beneath the desktop apps at Normal. The third ran at Normal with 8,040 MB in total and 1,372 MB free five minutes before it started; xUnit on SQLite took 140 s against 68 s on the take before it, and the browser suite 4.9 minutes against 3.7.

The decision is the one the scans got above: a budget that a test overruns while doing its work correctly is a wrong number. expect now waits fifteen seconds instead of five, for every assertion, in playwright.config.ts. A green run is no slower, because an assertion returns the moment its element appears; only a failing one waits longer, and the sixty-second test budget still caps it. The three tests that can name the request they wait on (the registration, the refused sign-in and the reset) wait on the response itself, which says what the server answered when the answer is wrong. The runner's scheduled task runs at Normal from now on.

Addendum, 2026-09-21: every check runs once, and every result is on the Admin tab

On the time a version took to reach the live site, Steve asked for each check to run only once, for every test to keep running, and for the effort to go into making the tests that run faster. Measured on 1.0.0.172 before anything changed, one version cost about twenty-four minutes: a precheck of about five minutes on the machine that ships, the gate at 417 seconds on the same tree, CI at 4 minutes 37 seconds on GitHub's runners, Deploy at 3 minutes 49, Deploy Cosmos at 1 minute 49 after it, and about two minutes of reading the roll. The suites ran three times: in the precheck, in the gate and in CI.

The gate is the one run. A separate precheck of the same suites on the same tree no longer runs before it; the gate commits nothing when it is red, so a red gate costs what a red precheck cost and a green one is not paid for twice. CI no longer runs on a push to main, because the only thing that pushes to main is a green gate, and the gate runs more than CI does: every xUnit test on both stores, the live Cosmos DB tests CI has no credential for, and the browser suite's store specs on Cosmos DB. CI keeps every suite for a pull request, which has had no gate.

Every result ships with the version. Each runner in the gate writes its own report into the gate's raw folder (Vitest and Playwright as JSON, a TRX file for each xUnit pass), and when every suite is green scripts/test_results.mjs reads them into data/test-results.json: every suite with its counts and its seconds, every test with its group, its name, its outcome and its milliseconds, one per line so a diff reads test by test, and the checks that are not tests. It is committed with the version, the image carries it beside the dataset, /api/admin/tests serves it, and the Admin tab's card "Every test, for this build" shows it, a suite opening to its tests with failures first and then the slowest. The slowest list is where the next speed-up is chosen from, which is the point of keeping the milliseconds. TestResultsTests holds the endpoint and holds a committed file to the only kind a green gate writes: every suite, counts equal to rows, nothing failed and nothing skipped.

const decode = (text) =>
  text
    .replace(/&quot;/g, '"')
    .replace(/&apos;/g, "'")
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&amp;/g, '&');

function attributes(tag) {
  const values = {};
  for (const match of tag.matchAll(/([A-Za-z]+)="([^"]*)"/g)) values[match[1]] = decode(match[2]);
  return values;
}

/** hh:mm:ss.fffffff, as TRX writes a duration, in milliseconds. */
function milliseconds(duration) {
  const parts = /^(\d+):(\d+):(\d+(?:\.\d+)?)$/.exec(duration ?? '');
  if (!parts) return 0;
  return Math.round((Number(parts[1]) * 3600 + Number(parts[2]) * 60 + Number(parts[3])) * 1000);
}

function trx(file) {
  const full = path.join(raw, file);
  if (!existsSync(full)) return null;
  const text = readFileSync(full, 'utf8');
  const classes = new Map();
  for (const match of text.matchAll(
    /<UnitTest\b[^>]*\bid="([^"]+)"[\s\S]*?<TestMethod\b([^>]*)>/g
  )) {
    classes.set(match[1], attributes(match[2]).className ?? '');
  }
  const tests = [];
  for (const match of text.matchAll(/<UnitTestResult\b([^>]*)>/g)) {
    const result = attributes(match[1]);
    const className = (classes.get(result.testId) ?? '').replace(/^TheYard\.Tests\./, '');
    let name = (result.testName ?? '').replace(/^TheYard\.Tests\./, '');
    if (className && name.startsWith(`${className}.`)) name = name.slice(className.length + 1);
    const outcome =
      result.outcome === 'Passed' ? 'p' : result.outcome === 'NotExecuted' ? 's' : 'f';
    tests.push([className, name, outcome, milliseconds(result.duration)]);
  }
  return tests;
}

Live from scripts/test_results.mjs, region trx, read from this build at 565827b.

The first take of this version went red on load and not on the change. With 1,548 MB free of 8,040 when it started and both sides running, one browser test on each store waited twenty seconds for the page to mount and it had not, while every other test of the run mounted it; openTheYard gives the mount thirty-five seconds now, inside the same sixty-second test budget, by the rule the scans and the expect got above. The same take read the first file of results: 1,487 tests across six suites, and the slowest xUnit tests are each class's first, at thirty to forty-five seconds, which is a host booting while the browser suite runs beside it. That is the next thing to make faster, and the card now shows it test by test.

What 1.0.0.173 measured, from the first gate that wrote the file: 1,487 tests, the gate at 417 seconds, the push at 12:30:50 CDT and both sites on the new version at 12:36:46, and the whole cycle, gate to live look, 907 seconds with the build cache still empty. The file then named the next two things to try, and the first take of 1.0.0.174 tried both: the Admin tab's browser spec, one file of twenty-three tests on one worker, declared parallel as the scans are, and the seven live Cosmos DB tests moved beside the xUnit pass on SQLite. Measured, the gate went from 417 seconds to 619, the browser pass on SQLite from 211 to 365, three of the live tests timed out and one phone test found a real defect of this version's own. On this machine, four cores and eight gigabytes shared with the developer's browser, more work at once is slower work, which the record's first section already said about the two sides of the gate. Both changes are taken back; nothing was committed by that take.

On the card itself, Steve asked for a small mark on a pass or a fail, a plus sign or a red cross, something that makes the result obvious at a glance. A tick in a green disc or a cross in a red one, from the status tokens because passed and failed are states, 44 pixels beside the card's sentence and 16 on every suite, check and test; a mark carries its own label only where no word beside it already says the same.

Addendum, 2026-09-22: the two cuts, and the name

The gate measured 487 to 745 seconds on the nights of 21 and 22 September, every check once on both stores, on the same four-core laptop; Steve's ceiling is five to ten minutes for the whole wall. Two cuts, each with the files that decide it spelled out, and neither runs on every ship:

  • dotnet format (39 to 53 seconds) runs only when a file the formatter reads changed since the last green commit: anything ending in .cs, .csproj, .slnx or .editorconfig. A ship that touches none of them cannot change what the formatter would say.
  • The two passes on Cosmos DB, xUnit on Cosmos DB (61 to 106 seconds) and the three store specs in the browser (93 to 128 seconds), run only when something under api/, under infra/cosmos/, or one of tests/e2e/store-toggle.spec.ts, tests/e2e/account.spec.ts and tests/e2e/admin.spec.ts changed. The seven live tests against the real account still run on every ship, and so does every other suite.

When the two passes are skipped, the gate says so to scripts/test_results.mjs (YARD_CARRY_COSMOS=1) and the results file carries those two suites forward from the previous version, row for row, each marked carried with the version whose gate ran them. The Admin tab's card says so beside the suite's name, and TestResultsTests holds the rule: only those two suites may be carried, and only from a named version. Every suite is still in every file, so a reader is never shown a build with a pass missing and no word about it.

The name. This record keeps its title as the record of the target; the living documents call it the one gate, because what it holds to is every check once before anything rolls, and the minutes are measured, not named. Together the two cuts take a frontend-only ship's gate from about 480 seconds to about 270 to 300; a ship that touches api/ runs everything. Steve chose both on 2026-09-22, and the 44 px pills that ship beside them.

Addendum, 2026-09-28: the ship's wall clock

Steve asked for the time from the start of the gate to both sites live to come down, with every check still run once. Measured first, from the gate's own log for 1.0.3.36 (queue script 1494): the gate 831 seconds, of which the .NET side took 224 and then the node side 541, the browser pass on SQLite 414 of those; the push at 838 seconds; both sites serving the new version at 1,483; and the reads after the roll (the Admin tab's health on both sites, the page sweep, the live look and the load proof) another 264 seconds, to 1,747. The lane that shipped waited on all of it.

The reads after the roll no longer hold the ship. The ship ends when both domains serve the new version and commit. The reads that follow run as a job of their own on the same runner, write their own log with one verdict line, and a red one is reported the moment it is read. Deploy and Deploy Cosmos already check the version, /readyz and the store before either finishes, so nothing that decides whether a version is live moved; on 1.0.3.36 this is the 264 seconds after the roll.

The gate script every lane copies is the current one. The gate lives outside this repository (ADR: Where a gate lives), and the template a lane starts from had fallen behind its copies: it still started the .NET side and the node side together, the shape the addendum of 21 September measured slower. It now runs the .NET side to its end and then the node side, and carries what the lane copies carry: the version gate, the two cuts, the test results file and the allowed list.

The coverage checks in parts, tried and taken back. On 1.0.3.36 the three checks that read every page the site lists, at 390, 1024 and 1280 wide, ran one after another on one browser worker, 307 seconds, while the other three workers finished early (394 seconds on that worker, 234 to 239 on each of the others). The trial read each width in four parts, every fourth page, with the file declared parallel so the four workers shared the twelve parts, and every page still read at every width. Measured on one take with every pass forced on so it compared like for like (queue script 1497):

1.0.3.36 In parts
Browser pass on SQLite 414 s 626 s
The coverage checks, test time 5.2 minutes about 18 minutes
The whole gate 831 s 1,131 s

Each part of a width took about as long as the whole width had taken alone, and one part was retried when the Admin tab did not draw inside thirty seconds, the starving the file's own comment names for more than three pages at once. The machine was busier on the trial (1,189 MB free with Chrome at 2,450 MB, against 1,236 MB and 1,270 MB), and the .NET side, which the change does not touch, went from 224 seconds to 279; the browser pass moved by 212 seconds, and the per-test times say where. The browser pass on this machine is bound by its four cores, and four workers already use them: spreading the same pages across more tabs at once is slower work, the finding of 21 September a third time. The parts are taken back and the file is as it was. The long worker is still the place to look, and the lever there is what one page costs to read at the phone width.

Addendum, 2026-09-29: stack, gate once, push once

On 29 September Steve asked for releases to stack instead of shipping one at a time, with the pipeline always taking the latest push. With the deploy group cancelling the run in flight (ADR: The deploy pipeline, addendum of 29 September), the rule for every lane that ships:

  • Stack. A lane's changes are local commits in order, each checked with the quick check only (the type check, the unit tests, the lint and the .NET tests that read what moved; about two minutes). No commit is rolled on its own.
  • Gate once. The full gate runs once, on the stacked head. A commit that turns it red is fixed on top; the whole gate is never run again per commit.
  • Push once, without looking. A lane pushes when its stack is green, without checking whether another roll is in flight: the pipeline takes the newest push and cancels the older roll.
  • Never wait on a roll. After the push the lane moves on. The proof that a version is live is the version the Actions run's own Verify step reports, not a lane polling both domains.

This version is the second push of the proof: it went to main while 1.0.3.40 was rolling.

Addendum, 2026-10-05: CI runs on every push again

On 21 September CI stopped running on a push to main, because the gate had just run every suite and each check was to run once. The cost showed up in a floor only CI measures: CI went unrun from 21 September to 3 October, and when it was run by hand on 3 October the coverage step was red, with branches at 67.7% against a floor of 68 (ADR: Counting what the tests cover). On 5 October, with the code under review, Steve asked for CI to run on every push again.

ci.yml now runs on a push to main as well as on a pull request and by hand. It runs on GitHub's runners beside Deploy and Deploy Cosmos and holds neither of them up, so the time from push to live does not change. What it adds is a public result on every commit, on a clean machine, and the coverage floor read on the commit that crosses it. GitHub's runners cost nothing on a public repository. A push that touches only the sample under samples/ skips it, as Deploy does. The gate stays the one that decides whether anything is pushed. CiTriggerTests holds the triggers.

Addendum, 2026-10-10: the coverage read waits on the page, not on the network

Steve asked on 10 October for the test suite to run faster on the laptop that ships (8 GB, four cores) with every step kept. Measured first from the gate's own logs of 9 October with their Playwright reports: the browser pass on SQLite was 545 to 742 seconds of every gate (52 to 72 per cent of it). Its two workers finish within two seconds of each other every time, so the pass is the sum of its test time divided by two; the order the specs run in moves nothing. coverage.spec.ts was 389 to 470 seconds of that sum of 1,024 to 1,246 seconds. Each of its three "every page" reads (one per width) took 130 to 170 seconds. On the morning of 10 October, with more running beside the gate, the pass took 1,134 seconds and the phone read overran its twelve-minute budget once.

Then what one page costs, phase by phase, every listed page once in one tab per width (queue 2021, mentor\logs\gatespeed-2021-pagecost.log): 166 pages in 204 to 217 seconds a width. The wait for a quiet network was 113 to 119 seconds of it; the fixed wait after the view's signal was 53 seconds; the reader itself was 4 to 8 seconds. Reading the page was a fifth of the time; waiting for nothing in particular was four fifths. networkidle is a 500 millisecond window with no request in it, and a view that polls (the vehicle page's room, the Admin cards) keeps that window from opening for a second or more.

The read waits on what it measures. everyPage opens a page on the load event, which is the browser's own word that the document is in with its sheets and its images. It then waits on the same signals as before (the view's announcement; on the Admin tab the workbench with no card on its way; on the API reference its panel), then on every face being loaded with two frames painted since (painted), then reads. Nothing a page is read for changed: the same reader returns the same nine facts for every listed page at the same three widths. The proof that the signals are enough was read before the change shipped. Every page at every width was read twice (once each way) with the facts compared page by page. The first reading (queue 2022, mentor\logs\gatespeed-2022-signals.log) found three document pages read before their document had arrived, because the view's announcement says home for a document and the window's own loading line is the signal; that line joined the signals. The second reading of the first draft hung on the inventory, because a wait on every picture never ends for a lazy picture below the fold, which the browser does not fetch until it scrolls into view; the wait is on the pictures in view. The reading that shipped (queue 2027, mentor\logs\gatespeed-2027-signals.log): 166 pages at each of 1280, 1024 and 390, facts differing on 0, missing 0, pages failing the look 0 both ways; one tab, one width, 219 to 86 seconds at 1280, 218 to 79 at 1024, 211 to 70 at 390. The first gate of this version (queue 2023) was then red on one page: a vehicle page at 390, read while the inventory it stands on was still loading, which one tab never saw and three tabs on a shared core did; every loading line a view shows (the inventory's, the Admin tab's, the account's) joined the signals, with the vehicle's own layout on a vehicle page. The second gate (queue 2028) ran the desk read into its twelve-minute budget twice on a wait with no end of its own, so every wait in the read now has one: a page that has not loaded in a minute fails the read by name, and a picture that fires neither load nor error holds it for five seconds at most.

/**
 * The page has what the reader measures: every face is loaded, so a width is the page's own and
 * not a fallback face's; every picture in view has arrived or failed, so a box has its final
 * size (a lazy picture below the fold is not asked for, because the browser will not fetch it
 * until it scrolls into view and the wait would never end); and the browser has painted two
 * frames since, so a layout that follows the last change is in. A named condition in place of a
 * timer: a timer is a guess at how long the page needs, and the guess was three hundred
 * milliseconds on about three hundred and eighty pages a run.
 */
async function painted(tab: Page): Promise<void> {
  await tab.evaluate(async () => {
    await document.fonts.ready;
    const inView = (image: HTMLImageElement) => {
      const box = image.getBoundingClientRect();
      return box.bottom > 0 && box.top < innerHeight && box.right > 0 && box.left < innerWidth;
    };
    const pictures = Promise.all(
      Array.from(document.images)
        .filter((image) => !image.complete && (image.loading !== 'lazy' || inView(image)))
        .map(
          (image) =>
            new Promise<void>((settled) => {
              image.addEventListener('load', () => settled(), { once: true });
              image.addEventListener('error', () => settled(), { once: true });
            })
        )
    );
    // A picture that fires neither load nor error (one whose source is swapped while it is
    // waited on, or one the browser decides not to fetch) must not hold the read for ever; after
    // five seconds the page is read as it stands, which is what a quiet network read too.
    await Promise.race([pictures, new Promise<void>((enough) => setTimeout(enough, 5000))]);
    await new Promise<void>((done) =>
      requestAnimationFrame(() => requestAnimationFrame(() => done()))
    );
  });
}

Live from tests/e2e/coverage.spec.ts, region painted, read from this build at 565827b.

What it is worth is read off the gate's own lines in the next addendum, before against after. The gate of this version is the first to run the read this way; a record cannot quote a number from a gate that has not run.

Addendum, 2026-10-10: the first wait, and the gate's own numbers for the coverage read

The gate's numbers for the version before this one (1.0.3.116, the coverage read on the page's own signals) come from mentor\logs\gatespeed-2029-gate.log with the results file it wrote, read against 1.0.3.115's. The browser pass on SQLite: 499 seconds against 465 (545 to 742 across 9 October). The three "every page" reads: 121, 105 and 113 seconds against 113, 117 and 103. The whole gate: 721 seconds against 957, with the two Cosmos DB passes skipped by the gate's own rule both times. Every suite ran, nothing was skipped that was not skipped before, and the pass was green on the third take (the first two are in the addendum above).

So the honest reading: in one tab the read fell from about 210 seconds a width to about 70, and at the gate it did not move. The gate reads three tabs a worker on two workers, six tabs on four cores beside the API and the preview server, and on a saturated machine a tab waiting for a quiet network was giving its core to another tab; take the wait away and the other tab was already using the core. The pass is bound by what the browser has to compute, not by what the test waits for. What the version keeps is worth having anyway: a read that waits on named conditions with an end to each instead of a heuristic window plus a timer. A developer running the spec alone gets the one-tab number. What it means for the next lever is plainer: the time is in rendering about 380 cold page loads of a 2.8 MB application; only fewer loads move the pass, or lighter ones.

Then the floor under every xUnit pass. The slowest test on both stores was one fact, PersistenceTests.A_database_that_will_not_open_leaves_the_site_serving_from_files, at 31 seconds on SQLite and 30.7 on Cosmos DB, with the next slowest at 8.8 on one store and 14.5 on the other. The test boots the host against a path that cannot be a database and reads the catalogue from the files. The host asks the store five times before it falls back, with waits of two seconds doubling to sixteen between the asks (ADR: The relational store). The thirty seconds were the waits rather than the asks; the policy's waits are already held by StoreSecondChanceTests with a wait that returns at once. The first wait is read from the configuration (Store:FirstWaitMs, two seconds unless set), the fallback test sets it to zero, and the five asks still happen. Nothing in the container changes: no deployed configuration names the setting. The floor under the xUnit pass drops from the 31 seconds of that one test to the next slowest class, and the gate's own line for this version is in the next addendum.

Addendum, 2026-10-10: the coverage read arrives in the app, and the gate's numbers for the first wait

The gate's numbers for 1.0.3.117 (the first wait as a setting), from mentor\logs\gatespeed-2030-gate.log: xUnit on SQLite 38 seconds against 50 to 57 on the day's gates before it (918 tests, the five rows of the new theory included); the seven live tests 47; xUnit booted on Cosmos DB 104 against 77 to 104, where the floor was never that one test; the whole gate 1,008 seconds with every pass run, against 1,021 to 1,270 for the ships of 9 October that touched api/. Green on take one.

Then the lever the addendum above pointed at: fewer loads. Every page of the coverage read was a cold load of the 2.8 MB application (about 380 of them a run); on four cores that rendering was the read's time. The read arrives in the app the way Back and Forward do. Each tab loads its first page cold. For every app page after it the address bar is set with a popstate event fired behind it, so the app re-reads the whole view from the address (useAddressBar, the back-forward region); home comes first, so the page's own signals start from nothing. A page the API serves on its own (the API reference, /about, the HTML addresses the sweep holds) is still a load of its own. The same reader returns the same nine facts for every listed page at every width, with every signal of the addendum above still waited on. Cold loads keep their own readers: the smoke spec opens pages cold, as do the landing spec, the drawn spec and the live quality review of every page on both domains.

The proof before it shipped (queue 2031, mentor\logs\gatespeed-2031-inapp.log): every page at every width read both ways (cold, then in the app) with the facts compared page by page: 166 pages at each of 1280, 1024 and 390, facts differing on 0, missing 0; one tab, one width, 84 to 39 seconds at 1280, 81 to 37 at 1024, 71 to 32 at 390. What that is worth at the gate, where six tabs share four cores, is the gate's own line for this version, in the next addendum.

/** The tab holds the application, not a page the API serves on its own. */
function appIsOpen(tab: Page): boolean {
  const address = tab.url();
  return address.includes('localhost:5173') && !/\/api\/|\/about$/.test(address);
}

/**
 * Reaches an address the way Back and Forward do: the address bar is set and a popstate event
 * fired, and the app re-reads the whole view from the address. Home first, so the page's own
 * loading signals start from nothing; a document window still open from the last page, or a
 * vehicle still drawn, would otherwise be read as the new page. A cold load of the 2.8 MB
 * application per page was most of the read's time on the gate's four cores; the first page of
 * each tab still loads cold, and so does every page the API serves on its own.
 */
async function arriveInApp(tab: Page, address: string): Promise<void> {
  await tab.evaluate(() => {
    window.history.pushState({}, '', '/');
    window.dispatchEvent(new PopStateEvent('popstate'));
  });
  await expect(tab.getByTestId('view-announcement')).toHaveText('The Yard, home', {
    timeout: 30_000,
  });
  await expect(tab.locator('dialog[open]')).toHaveCount(0, { timeout: 30_000 });
  await tab.evaluate((next) => {
    window.history.pushState({}, '', next);
    window.dispatchEvent(new PopStateEvent('popstate'));
  }, address);
}

Live from tests/e2e/coverage.spec.ts, region arrive-in-app, read from this build at 565827b.

Addendum, 2026-10-10: the gate's numbers for the read that arrives in the app, and the day in one table

The gate's numbers for 1.0.3.118 come from mentor\logs\gatespeed-2032-gate.log with the results file it wrote. The three "every page" reads 63, 70 and 58 seconds against 121, 105 and 113 at 1.0.3.116 (and 113, 117 and 103 at 1.0.3.115); the coverage spec 196 seconds of test time against 337 to 344; the browser pass on SQLite 401 seconds against 465 to 511 on the day's gates before it and 545 to 742 across 9 October; the whole gate 655 seconds against 875 to 905 for the 9 October ships of the same shape (the two Cosmos DB passes skipped by the gate's own rule). Every one of the 156 browser tests ran, green on take one. So the lever the two addenda above pointed at was the right one: fewer cold loads of the application moved the pass where shorter waits did not.

The day, measured on the gate's own lines, is tabled on the Performance page under "The gate on an 8 GB laptop, measured on 10 October": what was kept (the signals; the first wait as a setting; the read arriving in the app; in the gate copies, prettier's cache with the scoped format check) and what was measured then dropped (the order the specs run in; more browser workers), each with its log. The target this record is named for stands: five minutes, and 655 seconds is not it. What is left is the browser pass at 401 seconds on four cores, of which the coverage spec is now about a quarter, and the rest is 150 tests that each open the site cold.

Addendum, 2026-10-13: the gate runs where the memory is

From 1.0.3.123 the gate runs on a GitHub runner with 16 GB, not on the laptop that ships (ADR: The gate runs where the memory is). Every check in this record runs there, in the same order, with the same two cuts; The Shed's suites run beside it on Windows. The laptop builds the commit, pushes it to gate/<version>, and fast-forwards main only when the run is green. The reason is the one this record kept measuring: on an 8 GB laptop the gate waited for memory, and started short of it, specs the change never touched timed out.

Addendum, 2026-10-16 (1.0.3.145): the commit on main is the tested commit plus its results

The ship pushes the commit to gate/<version>, waits for the gate, then folds the results files the gate produced (data/test-results.json, the README's counts, the Built with AI page) into that same commit and fast-forwards main to it. So main's hash is never the hash the gate ran on: the gate tested the code, and main holds the code with three generated files added. Two things make that safe, and both are checked. The fold touches only files the ship script lists, and that script (ship-from-branch.ps1, which lives in the operator's folder on the shipping PC, outside this repository, as the ship's own tooling always has) refuses the commit if anything else changed. CI on main then runs every suite again on the folded commit, and the Deploy workflows read the version from that commit. The review of 13 October asked for this to be written down; it is this paragraph. A ship that wanted main's hash to equal the gate's would have to produce the results before the gate ran, which is the thing the gate exists to prevent.

The ship also leaves its gate/<version> branch on GitHub. From 1.0.3.145 that script, outside this repository, deletes the branch once both domains answer the new version, so the branch list holds only the gates still in flight.

Addendum, 2026-10-19 (1.0.3.182): CI on main was red for four versions while the gate was green

CI on main failed on 1.0.3.178, 179, 180 and 181, and on each the gate had been green and the roll had been read live. The one failure was the memory rule that names every collection a long-lived object keeps (ADR: The store does the work). CI measures coverage and the gate does not, and the coverage collector adds its own tracker classes to the assemblies it measures, each with a static hit array, so on CI's run the scan found collections this codebase never wrote. The test now reads only types in TheYard's own namespaces, and a test holds that it does. Before 1.0.3.178 CI's reds on main had other causes: the coverage floor on 1.0.3.173 to 176, which 1.0.3.177 brought back over, and a navigation the browser aborted on 1.0.3.171 and 172.

The gap was in the reading, not the gate: the ship read the gate and the roll and never read CI on main, so four reds sat on the public commit list unseen. From this version the after-roll reads (in the operator's folder, outside this repository, beside the ship script) wait for CI on the commit main holds and count a red there as a red after the roll.

Addendum, 2026-10-19 (1.0.3.183): a red deferred to the remote run is read there, and the gate at 825 seconds

From 1.0.3.178 to 181 the ship accepted browser reds on the PC's dev server on the word that the gate on GitHub, which runs the browser suite on the built bundle, decides. The gate's browser runs were green each time, so the outcome held. The rule did not: CI's api job on main was red on all four pushes and nothing read it, and the 1.0.3.181 ship's log called two feature specs (the Pin row on the Admin bench, and the landing page's old address) "coverage sweeps", which they are not. An outside review found both.

The rule is now a check in the ship script, which lives beside this repository in the operator's folder: a spec left red locally is written into the ship's log by the name Playwright gives it, never by a kind; the ship already waits for the gate's own run and goes no further unless it is green; and after main is fast-forwarded it waits for every workflow the push started on that commit (CI, Deploy, Deploy Cosmos and Deploy Render) and reads each to its conclusion with gh. A red job on any of them fails the ship after the fact: the log names the workflow and the job, a red marker file sits beside the log, and the after-roll reads count it red. The roll is not undone, because main already holds the commit and the deploy has read it live; the red is the next ship's first job. The same check lives in mentor\ as the ship's own rule.

The gate itself ran 825 seconds on 1.0.3.181 against this record's five-minute target and Steve's five to ten. The gate's own results file breaks it down: the browser suite on SQLite 268 s, on Cosmos DB 118 s, the xUnit suite booted on Cosmos DB 138 s, on SQLite 36 s, the live Cosmos DB tests 41 s, Vitest 6 s, the checks 55 s (dotnet format 44 of them), and the build and restore the rest, about 160 s. Every suite runs one after another on one runner. Getting under ten minutes means two jobs, one per store, each building once and running its own suites: about 160 s of build, 55 s of checks and the longer store's 297 s of suites, near 520 s. It costs no money on a public repository; it costs a second build's runner minutes, a results writer that reads two artifacts instead of one, and a gate whose red names the job as well as the suite. It is not built in this version.

Addendum, 2026-10-21 (1.0.4.24): the record shows a retry

The browser suite runs with --retries=1 in gate.yml, and scripts/test_results.mjs wrote a test that failed and then passed on that retry as a plain pass, so the shipped results file could not show a first-attempt failure and TestResultsTests certified that nothing failed. From 1.0.4.24 a retried pass is written r, and TestResultsTests refuses a shipped file holding one, by the test's name: the retry stays (one machine's slow minute is not a defect), and the record says it happened, so the version after it fixes the test before it ships. The README's "How N is counted" paragraph says so, and its one xUnit number, wherever the README and Built with AI state it, is written after the gate from the file's xunit-sqlite count (staging\polishlane\readme_results.py), where it used to be Facts and InlineData rows counted by PublicFaceTests, which missed every MemberData row and said 1210 above a run of 1292.

Addendum, 2026-10-25 (1.0.4.72): the SDK download cannot turn a good version red

The first gate for 1.0.4.72 (run 40224307813) failed in its first minute without running a check: setup-dotnet resolved 10.0.x to SDK 10.0.403 and runtime 10.0.14, the download answered 409, and every check after it was reported red. The version was sound; the gate had bet every run on one download. From this version every job in ci.yml and gate.yml gets its SDK through one composite action, .github/actions/dotnet: it tries setup-dotnet twice, a minute apart, and if both fail it builds with the runner image's own .NET 10 SDK and names it in a warning; it fails only when the runner has no .NET 10 SDK at all, and says that in one line. DotnetInstallTests holds every job to the action and the action to its two tries and its fallback: red on the prior workflows, which called setup-dotnet directly.

Store

The Yard, home

Welcome to The Yard

Steven Stout’s showcase: a used-vehicle auction on .NET and React, built and run by one engineer, live on Azure, with its decisions written down.

Answering in

unread

0255075100

server time at p95, the usual five minutes of the last day.

Azure this month

unread

the bill, by resource, read from Azure. The Admin tab shows every line.

Since the last roll

unread

 

rolled blue and green, with no downtime.

People who have visited

unread

30 days agotoday

Scanners, my own tools' and my own reads left out.

  •    
  •    
  •    

Tech stack

Read from the build, never typed by hand

What TheYard runs on

What we use, where it runs, the version as built, and the file that proves it.

  • .NET 1014 projects
  • React 19.3TypeScript 7.0
  • Azure6 Bicep files
  • 9GitHub Actions workflows
LayerWhat we useWhere it runsVersionProof
Backendthe API
Backendthe API.NET and ASP.NET CoreApp Service, the API container10.0TheYard.Api.csproj
Entity Framework Core (SQL Server)App Service, talking to Azure SQL10.0.12TheYard.Infrastructure.csproj
Identity with JWT bearer authApp Service, the API container10.0.12TheYard.Api.csproj
OpenAPI with Scalar API docsApp Service, /api docs2.17.13TheYard.Api.csproj
Frontendthe page
Frontendthe pageReactYour browser19.3package.json
TypeScriptYour browser7.0package.json
ViteBuild step, in the container image8.3package.json
Server-side rendering on NodeApp Service, the renderer container24render/Dockerfile
Datatwo stores, one port
Datatwo stores, one portAzure SQL DatabaseAzure, managed SQLEF Core 10.0.12TheYard.Infrastructure.csproj
Azure Cosmos DBAzure, NoSQLSDK 3.63.2TheYard.Infrastructure.Cosmos.csproj
Azureinfrastructure as code
Azureinfrastructure as codeApp Service and Container Registry (Linux container)Azure App Service and ACRlive resourceappservice.bicep
Front Door (written, switched off)Off: the subscription refuses Front Door, so the Netlify edge stands in front todayoffmain.bicep
Application Insights with OpenTelemetryAzure Monitor1.6.0TheYard.Api.csproj
Communication Services emailAzure Communication Services1.1.0TheYard.Api.csproj
Azure Functions, Flex Consumption (clock, snapshot, tracker merger, Sheet mirror, ATS sweep, daily card)func-jobhunt-ss, Flex Consumption.NET 10 isolatedJobHunt.Functions.csproj
Bicep for every resource it deploysDeployed from the repository6 Bicep filesinfra/
Edge
EdgeNetlify edge in front of the siteNetlify edge networklive resourcenetlify.toml
Quality gatesevery push
Quality gatesevery pushxUnitGitHub Actions runners2.9.3TheYard.Tests.csproj
NetArchTest architecture rulesGitHub Actions runners1.4.5TheYard.Tests.csproj
VitestGitHub Actions runners5.0package.json
Playwright with axe accessibility checksGitHub Actions runners, against the built site1.63package.json
oxlint and PrettierGitHub Actions runners1.86 and 3.9package.json
Delivery and AIhow it ships
Delivery and AIhow it shipsGitHub Actions (azure-read, blog-post, ci, deploy-function, deploy-render, deploy, edge-branch, gate, nightly)GitHub9 workflowsworkflows/
Built with Claude Code under the test gateDeveloper machine, then the gateevery pushgate.yml
Claude Code on GitHub Actions runs the job hunt's nightly lanesGitHub Actions runners, on the Azure share4 AM and every 4 hnightly.yml

Generated, not typed. A build step reads the project files, package.json, the Dockerfiles, the Bicep and the workflows; a test fails CI when this table drifts from the repository.

How it is built

How it is run

Who and why