Skip to content

Return a test run's results instead of scraping them out of its logs - #797

Open
Quenty wants to merge 10 commits into
users/jamesonnen/structured-test-resultsfrom
users/quenty/test-results-table
Open

Return a test run's results instead of scraping them out of its logs#797
Quenty wants to merge 10 commits into
users/jamesonnen/structured-test-resultsfrom
users/quenty/test-results-table

Conversation

@Quenty

@Quenty Quenty commented Aug 11, 2026

Copy link
Copy Markdown
Owner

A test run now returns a table with its verdict, test and suite counts, and a capped list of failed test names, instead of announcing a failing suite by throwing and leaving the counts to be scraped back out of log text that Open Cloud truncates on long runs. nevermore test and nevermore batch test read that verdict, so a failing suite is detected structurally rather than by finding a jest summary line that may not have survived truncation. Every package's test script was updated to return its results; one that has not been updated returns nothing and behaves exactly as before.

Quenty added 4 commits August 10, 2026 17:29
An executed script's return value had nowhere to go: the plugin discarded
everything pcall handed back, and a caller could only learn what a run
produced by reading its printed output. Anything a caller must read back
exactly — test counts, for instance — had to be scraped out of log text.

The plugin's execute action now marshals every returned value onto
scriptComplete's payload.returnValues, and it surfaces on both ExecResult
and StudioBridgeResult. Tables are walked recursively (array-like tables
stay arrays), Roblox datatypes reuse the SerializedValue shapes, and
cycles, over-deep nesting and non-finite numbers are marked rather than
producing JSON the server cannot decode. The field stays absent whenever
no scriptComplete arrived, which keeps "we never learned what it
returned" distinct from "it returned nothing".

Nothing consumes the new field yet.
…sult

Test runs learn everything about themselves by scraping the engine's log
output, and Open Cloud truncates those logs on a long run — a script
printing 20,001 lines came back with 7,627 of them, and the window size
varies, so there is no threshold to stay under. Results have to travel as
a value instead of as text.

ScriptRunResult gains returnValues, filled in by both transports: the
cloud context reads the task's output.results, the local context takes
what the Studio bridge marshalled. LuauTask.output was typed as an array
of { value?: string } wrappers, which does not exist — the results are a
flat array of natively typed values, so a returned Lua table is real
nested JSON. Nothing read it, so nothing noticed.

returnValues is absent, not empty, when a transport delivered no result
at all: an oversize return value fails a cloud task outright rather than
truncating it (~4MB fails, ~2MB arrives intact), leaving no output and no
error message, and a later consumer needs to tell that apart from a
script that returned nothing so it knows when falling back to the logs is
worth it.

Nothing consumes the new field yet.
A test run learned everything about itself from its own printed output, and
the engine truncates a long run's logs — so the counts went missing on
exactly the runs where they matter most. The runner had them all along and
threw them away: runTestsIfNeededAsync returned a boolean, and a failing
suite was announced by calling error(), which means the failing case never
reached a return at all.

runTestsIfNeededAsync now returns a TestRunResults table — the verdict, the
test and suite counts, and a capped list of failed test names with their
first message — or nil when no test run was attempted, which is still how a
real game server tells itself apart from a test place. Every path that used
to error() fills the table in instead, including a rejected Jest run and an
interrupted one. The table carries only strings, numbers, booleans and
tables of those, which the cloud and the Studio bridge marshal identically,
and the failure list is capped because an oversize return value fails the
whole run rather than arriving truncated.

Nothing outside Open Cloud reads the returned table, so the local path still
carries its verdict out through ProcessService:ExitAsync — now driven by the
results rather than by whether _runTestsAsync threw.

Test scripts return what they get:

	local results = NevermoreTestRunnerUtils.runTestsIfNeededAsync(root)
	if results then
		return results
	end

A script still written the old way returns nothing and keeps working, since
a table is as truthy as the boolean it replaced.
…s logs

The runner returns its results now, so the CLI reads them. That closes the
hole the change opened: a failing suite used to fail the cloud task by
throwing, and without reading the returned verdict a failure whose jest
report fell outside the truncated log window would have come back a pass.

runSingleTestAsync takes the verdict and the counts from the returned results
when a run produced them, falling back to log scraping when it did not. It
also accepts returned results as proof the runner ran, which the required
jest report was only ever standing in for. Tracebacks still fail a run
whatever the results say — jest cannot count a deferred-callback crash.

The batch runner captures what each package's script returns and folds the
counts into the summary it already prints, so both the PASS/FAIL marker and
the summary's success field come from the run's own verdict rather than from
whether the script threw. The parser prefers those counts over the ones in a
package's log section, reports the reason the runner gave instead of calling
every failure a Luau error, and refuses to believe a summary that claims a
pass alongside failed tests. Only counts travel in the summary: it is one log
line, and the failure text is already in the section.

A package whose test script returns nothing behaves exactly as before.
@Quenty
Quenty deployed to integration August 11, 2026 00:59 — with GitHub Actions Active
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Test Results

Package Status Error Try it
@quenty/access Failed (3.9s) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/animations Failed (835ms) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/binder Failed (1.7s) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/chatproviderservice Failed (1.4s) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/deathreport Failed (901ms) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/gameconfig Failed (1.5s) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/gameproductservice Failed (2.0s) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/ik Failed (934ms) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/ragdoll Failed (1.5s) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/playerbinder Failed (985ms) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/saveslot Failed (15.4s) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/settings Failed (1.4s) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/settings-inputkeymap Failed (1.2s) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/playerhumanoidbinder Failed (1.5s) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/rogue-humanoid Failed (1.2s) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/rogue-properties Failed (2.3s) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/soundgroup Failed (951ms) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/blend Failed (801ms) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/snackbar Failed (851ms) no output could be attributed to this package (276989 chars received, 53 BEGI... Open | Play
@quenty/camera ✅ Passed (18/18) (1.0s) Open | Play
@quenty/brine ✅ Passed (88/90) (1.1s) Open | Play
@quenty/resetservice ✅ Passed (17/17) (1.2s) Open | Play
@quenty/clienttranslator ✅ Passed (170/170) (2.2s) Open | Play
@quenty/inputkeymaputils ✅ Passed (6/6) (1.1s) Open | Play
@quenty/scoredactionservice ✅ Passed (107/107) (2.1s) Open | Play
@quenty/fakeskybox ✅ Passed (3/3) (918ms) Open | Play
@quenty/elo ✅ Passed (5/5) (868ms) Open | Play
@quenty/genericscreenguiprovider ✅ Passed (8/8) (1.1s) Open | Play
@quenty/observablecollection ✅ Passed (204/204) (1.9s) Open | Play
@quenty/cmdrservice ⚠️ Passed (0/0) (695ms) Open | Play
@quenty/secrets ✅ Passed (2/2) (1.0s) Open | Play
@quenty/lipsum ✅ Passed (4/4) (751ms) Open | Play
@quenty/transitionmodel ✅ Passed (30/30) (951ms) Open | Play
@quenty/brio ✅ Passed (35/35) (818ms) Open | Play
@quenty/conditions ⚠️ Passed (0/0) (850ms) Open | Play
@quenty/tie ✅ Passed (35/35) (1.1s) Open | Play
@quenty/characterutils ✅ Passed (52/52) (1.4s) Open | Play
@quenty/clipcharacters ✅ Passed (1/1) (867ms) Open | Play
@quenty/coreguienabler ✅ Passed (5/5) (1.0s) Open | Play
@quenty/playerutils ✅ Passed (36/36) (984ms) Open | Play
@quenty/permissionprovider ✅ Passed (68/68) (1.5s) Open | Play
@quenty/friendutils ✅ Passed (13/13) (867ms) Open | Play
@quenty/humanoidtracker ✅ Passed (22/22) (1.1s) Open | Play
@quenty/instanceutils ✅ Passed (15/15) (784ms) Open | Play
@quenty/datastore ✅ Passed (338/338) (42.8s) Open | Play
@quenty/remoting ✅ Passed (70/70) (1.1s) Open | Play
@quenty/roblox-api-dump ✅ Passed (32/33) (1.3s) Open | Play
@quenty/streamingutils ✅ Passed (6/6) (1.1s) Open | Play
@quenty/teleportserviceutils ✅ Passed (144/144) (6.8s) Open | Play
@quenty/screenshothudservice ⚠️ Passed (0/0) (762ms) Open | Play
@quenty/influxdbclient ✅ Passed (163/163) (1.6s) Open | Play
@quenty/receiptprocessing ✅ Passed (4/4) (784ms) Open | Play
@quenty/ellipticcurvecryptography ✅ Passed (3/3) (20.8s) Open | Play
@quenty/experiencecalculator ✅ Passed (2/2) (568ms) Open | Play
@quenty/fzy ✅ Passed (31/31) (667ms) Open | Play
@quenty/grouputils ✅ Passed (15/15) (534ms) Open | Play
@quenty/maid ✅ Passed (5/5) (501ms) Open | Play
@quenty/userserviceutils ✅ Passed (6/6) (967ms) Open | Play
@quenty/servicebag ✅ Passed (13/13) (802ms) Open | Play
@quenty/marketplaceutils ✅ Passed (20/20) (651ms) Open | Play
@quenty/playermock ✅ Passed (188/188) (1.2s) Open | Play
@quenty/playersservicepromises ✅ Passed (3/3) (634ms) Open | Play
@quenty/playerthumbnailutils ✅ Passed (4/4) (601ms) Open | Play
@quenty/rx ✅ Passed (3/3) (751ms) Open | Play
@quenty/nevermoreclimanifest ✅ Passed (12/12) (568ms) Open | Play
@quenty/octree ✅ Passed (66/66) (969ms) Open | Play
@quenty/promise ✅ Passed (119/119) (2.0s) Open | Play
@quenty/pagesutils ✅ Passed (8/8) (601ms) Open | Play
@quenty/promisemaid ✅ Passed (7/7) (635ms) Open | Play
@quenty/randomutils ✅ Passed (5/5) (484ms) Open | Play
@quenty/steputils ✅ Passed (44/44) (985ms) Open | Play
@quenty/string ✅ Passed (48/48) (553ms) Open | Play
@quenty/throttle ✅ Passed (5/5) (517ms) Open | Play

73 packages tested, 54 passed, 19 failed in 3m15s
⚠️ 4 package(s) ran 0 tests — check test discovery · View logs

Deploy Results

Package Status Try it
@quenty/integration ✅ Deployed (v177) (21.8s) Open | Play

1 package deployed, 1 passed, 0 failed in 22.9s · View logs

Quenty added 2 commits August 10, 2026 18:18
Jest.runCLI resolves with { globalConfig, results } and every count lives on
the inner results. This module read them off the outer wrapper, where they
are all nil — so the numFailedTests > 0 check it used to gate error() on
never fired once in its whole life, and test failures were only ever caught
by scraping jest's printed summary. Carrying that read forward into the
returned results table made the table itself inert: zero failures over zero
tests, reported as a pass, on a run that failed.

Nothing about that was loud. A nil count defaults to zero, and zero failures
is what a clean run looks like, so a wrong read is spelled identically to
good news. _resultsFromJest now accepts either shape — wrapper or bare
AggregatedResult — so a jest-lua change cannot silently zero the counts
again, and it fails closed when it recognizes neither: a shape this cannot
read has no counts, and a run with no counts is not a pass. A zero
numTotalTests is still a readable result, since a package with no specs
legitimately ran and found nothing.

The counts are now also printed. That line is the one piece of evidence that
survives whatever happens to the return channel, and its absence is how you
tell a structured read that worked from one that did not.
The structured results channel shipped inert and every check passed. A run
whose counts were scraped out of log text produces output identical to one
that returned them, so the fallback was invisible: the counts came back all
zeros, the verdict quietly fell back to scraping, and the only evidence was
failure reasons whose wording happened to be the log parser's.

Both readers now state provenance rather than implying it. BatchPackageResult
and SingleTestResult carry countsSource, the batch parser reports how many
packages returned counts on every run, and falling back to scraping is a
warning naming the packages — louder than the success path, since it is the
case someone has to act on. A run whose returned totals disagree with its own
jest report warns too: that mismatch is exactly what the zeroed counts looked
like, and nothing was watching for it.

Aggregated batch mode was also dropping the counts it had. One execution
covers every package, so the batch runner splits per-package results into the
summary and the log parser reads them back — but runSingleTestAsync then
re-scraped each section and threw the structured counts away. The parser hands
them over on ScriptRunResult.testResults instead, which the runner prefers
over decoding a return value that belongs to no single package, and which also
tells it the batch already reported provenance so it does not repeat it per
package. The summary carries ranJest so a zero count is never mistaken for
"no tests found".
@Quenty
Quenty deployed to integration August 11, 2026 01:20 — with GitHub Actions Active
Quenty added 2 commits August 10, 2026 18:31
…field

Every package failed, including ones where every test passed. The verdict
included `result.success ~= false`, and jest-lua's AggregatedResult.success is
inverted: TestScheduler assigns `anyTestFailures or snapshot.failure or
anyReporterErrors` where upstream jest negates that whole expression
(TestScheduler.lua:434). So it is false on a clean run and true on a failing
one, and a passing suite was the only kind that could falsify the conjunct.

That field is now not consulted at all. Reading it in the other sense would
work today and break the day the missing `not` is restored, so the verdict
reads the underlying signals instead: failed tests, failed and runtime-error
suites, an interrupted run, and a failed snapshot check — which was the only
thing `success` covered that the counts do not. A reporter error leaves no
trace on the result and is simply unavailable; nothing else it contributed is
lost.

The failure reason was a design flaw in its own right: it formatted the counts
into "%d test(s) and %d test suite(s) failed" whether or not they were zero,
so a failed package announced itself as "0 test(s) and 0 test suite(s)
failed". A reason asserting nothing failed reads as neither verdict, and it is
what made the bug behind it hard to see. It is now assembled from the causes
that actually hold, and names its own confusion when none of them do, so a
reason built out of zeros cannot be constructed.
…rives

Clean counts on every package with every package failed is the signature of a
runner reading the wrong field, not of broken tests — and the readers had
nothing to say about it. The failure reasons looked ordinary and the counts
were right, so the only clue was that the numbers and the verdict disagreed
and nobody was comparing them.

Both readers now describe a failure its own counts do not account for: zero
failed tests, zero failed suites, and a failure anyway. The verdict is not
overturned — a runner may know something its counts cannot express, an
interrupted run or a snapshot check — so this reports rather than corrects,
and the batch parser names every package it happened to, since all of them at
once is the shape that identifies the cause.

structuredFailureReasons also stops repeating itself: the runner's own message
restates the counts whenever a count explains the failure, so it is only
included when nothing else can say why.
@Quenty
Quenty deployed to integration August 11, 2026 01:32 — with GitHub Actions Active
@Quenty
Quenty force-pushed the users/jamesonnen/structured-test-results branch from 50dbc21 to a79ce46 Compare August 12, 2026 00:43
Quenty added 2 commits August 11, 2026 17:55
…ounting suites

Two ways this could still report a pass on a failing run, and the reason it
kept happening.

findAggregatedResult accepted any table with a numeric numTotalTests, and every
other count defaulted to 0 when it could not be read. So a jest-lua rename of
numFailedTests, numFailedTestSuites, numRuntimeErrorTestSuites or
wasInterrupted reproduced the original bug exactly: all counts zero, verdict
pass, green CI. Not hypothetical — the port already spells the per-suite field
numFailingTests next to the aggregate numFailedTests. Validating field names
one at a time only ever covers the names known when the check was written, so
the sum is asserted instead: total == passed + failed + skipped holds by
construction (helpers.lua:91 derives numTotalTests from exactly those
per-suite counts), and it catches a rename of any count, named or not. Every
count the verdict reads must now also be present and numeric, and a mismatch
fails closed.

suitesFailed added numFailedTestSuites to numRuntimeErrorTestSuites, but
jest-lua increments both for one suite that failed to run — the runtime-error
counter unconditionally, then numFailedTestSuites through the separate
`numFailingTests > 0 or testExecError` branch. One broken suite counted twice,
and a single-spec package reported "2 of 1 suite(s) failed". It reports
numFailedTestSuites now and keeps numRuntimeErrorTestSuites as its own verdict
term, which is the only count that sees a suite both skipped and broken.

Both bugs, and the two before them, were untestable rather than subtle: a
module-scope require("Jest") stops the whole file loading in any harness
without Jest and the Nevermore loader. The logic moved to NevermoreTestResults
— no loader, no Jest, no print or warn, plain tables in and out — and
NevermoreTestRunnerUtils keeps the Roblox-facing half and re-exports the types,
so callers see no change. A dependency-free Luau module needs no loader line;
312 of the repo's package modules already omit it.

The tests run under Lune, from the package: npm test. 44 assertions, no harness
and no mocks, covering both directions of every verdict term and the shapes
that must fail closed. Their proper home is a jest test target, which needs a
placeId this cannot mint; Lune needs no credentials, so Lune is what exists.

Also fixes a red CI job: a doc comment on a local function used --[=[ … ]=],
which moonwave-extractor treats as a doc block and rejects with "Function
requires @Within tag" — and it aborts on the first diagnostic, so nothing else
was being checked either. Local helpers use --[[ … ]] now, and *.test.luau is
excluded from lint:luau since @lune/* requires cannot resolve under luau-lsp.
… report

The batch path never relaxed requireTestReport, so the structured channel
changed nothing in the case it exists for. A CI batch puts dozens of packages
through one log window: a section keeps its END marker long after its jest
summary was dropped, and the parser then failed the package with "no jest
report in output — nothing proves any test ran" while its summary carried
311/311. A package whose section was lost entirely failed with "no output could
be attributed". Both now defer to returned counts, which are that proof
directly rather than a stand-in for it, and both still apply unchanged to a
package that returned none.

Judging a package on counts alone is a narrower verdict than judging it with
its log — nothing checks it for tracebacks, which jest cannot count and only
the log shows — so those packages are named in a warning rather than passing
quietly.

ranJest is now consulted instead of merely transported. A package with a
jest.config on disk whose run reports it never reached jest did not test
anything: the built place lost the config, and a Rojo regression that stops
shipping it would otherwise turn a real suite into a silent green, since a
smoke-test result retires the required jest report. That fails, and jest
running but finding no tests warns. A package with no jest.config still smoke
tests as before.
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