fix(core): fail the QA contains assertion on a non-evaluable actual instead of silently passing (#7256) - #7348
Conversation
… instead of silently passing (#7256) `TestRunner.assert`'s `case 'contains':` handled an array (membership) and a string (substring) and had no `else`, so `undefined` — a typo'd `field` path or a response shape that moved — plus `null`, a number, a boolean or an object fell out of the switch throwing nothing, and the assertion reported PASSED. A suite asserting `contains` against a field the result does not carry was asserting nothing, and CI believed the green. `contains` was the only path in this engine that could decide "no comparison applies here" and report success; every other unhandled shape already throws. An assertion the engine cannot evaluate is now a FAILED assertion, with a message that names the field, the operator and the runtime type the path resolved to, and says which of the fixture or the assertion is the suspect: `undefined`/`null` mean the path did not resolve, anything else means the path resolved and the operator does not apply. The two evaluable shapes are unchanged in both directions. Pinned in a new `runner.test.ts`, including the sibling operators, which do not carry this defect — `not_contains`/`gt`/`gte`/`lt`/`lte`/`error` are declared in `TestAssertionTypeSchema` with no branch in the runner, and were already refused loudly at `default:`. Refs #7256, #6247 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdBxv9i73dqkDD2QcnvNzd
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 19 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also reference the affected code. These are read-only:
|
…er than passes (#7256) The `os test` section already explains what stops a MALFORMED SUITE from reporting success (the load-site `TestSuiteSchema` parse). It said nothing about the assertion engine, where the same class of false green lived: a `contains` pointed at a path the response does not carry used to fall out of the switch and report ✅. Records the rule and the two operators an author reaches for instead — `is_null` for absence, `equals` for a scalar — so the new error message lands on a page that agrees with it. Refs #7256 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdBxv9i73dqkDD2QcnvNzd
Closes #7256. Follows #6247 (PR #7255), which enforced
TestSuiteSchemaat theos testload site and is what makes this engine's verdicts load-bearing.The defect
TestRunner.assert(packages/core/src/qa/runner.ts) handledcontainsfor the two shapes it can evaluate and had noelse:undefined(the common case: a typo'dfieldpath, or a response shape that moved),null, a number, a boolean or an object fell straight out of the switch and the assertion reported PASSED. A scenario asserting{ field: "body.data.items", operator: "contains", expectedValue: "acme" }against a response with nobody.data.itemsat all reported ✅ — the assertion that was supposed to be the test is the thing that silently disappears, and CI believes the green.containswas the only path in this engine that could decide "no comparison applies here" and report success. Every other unhandled shape already fails loud: an operator with no branch throwsUnknown assertion operator, an action type with no adapter branch throwsUnsupported action type in HttpAdapter, andequals/not_equals/is_null/not_nullcompare unconditionally. This closes the asymmetry rather than adding a new posture.The fix
An assertion the engine cannot evaluate is a failed assertion (issue option 1). The message is written for the author who has to act on it — it names the field, the operator and the runtime type the path actually resolved to, then says which of the two things is wrong:
undefined/null→ the path did not resolve, so the fixture (the field path, or the response shape it was written against) is the suspect.containstests array membership and string substrings only. Useequalsto compare a scalar, or point the field at the array or string you meant to look inside."nulland arrays are named as themselves rather thantypeof'sobject, because those are exactly the two shapes acontainsauthor needs told apart from a plain record.Option 2 from the issue (
String(actual).includes(…)) is not taken: it would makeundefined"contain" the substring"defi", trading one false green for a stranger one.The
os testsection ofcontent/docs/deployment/cli.mdxalready explained what stops a malformed suite from reporting success; it now also states the assertion-engine half of the same rule.Blast radius — measured, not assumed
Zero in-tree cases were passing vacuously.
os testis the runner's only consumer in the repository, and there are no Quality Protocol suite documents to run.Run A — the default glob on the loud build, from the repo root and each of the three example apps:
No test files found matching: qa/*.test.json, exit 0 in all four. None of the apps has aqa/directory; all three runvitest runforpnpm test.Run B — the repo-wide sweep. Every
*.test.jsonin the tree (node_modules excluded) is threetsconfig.test.jsonfiles; fed toos testexplicitly, each is refused at the load site by #6247'sTestSuiteSchemaparse. The only JSON with a top-levelscenarioskey ispackages/spec/liveness/qa.json, the ledger.packages/qa/{dogfood,downstream-contract,http-conformance}are vitest suites that never touchTestRunner. No workflow invokesos test.Run C — positive control, so "zero suites" is not an untested claim about the loud path. Same fixture and the real
HttpTestAdapter, against a stand-in server returning{"ok":true,"data":{"total":0},"name":"acme corp","tags":["acme","globex"]}:f3f855accontainsondata.items— path absentgot undefinedcontainsonbody.data.items— the documentedbody.*conventiongot undefinedcontainsondata.total— a numbergot numbercontainsontags— a real arraycontainsonname— a real stringSUCCESS: All 5 scenarios passed.FAILED: 3 scenarios failed. 2 passed.Exactly the three vacuous scenarios flip; both evaluable controls stay green.
Nothing in-repo relied on silent-pass as "field may be absent" semantics, so there was no semantics decision to escalate and no fixture to repair. Downstream suites are what will see red, and every case they see is a test that was never running.
Two adjacent defects found while measuring, filed rather than folded (different files):
os testwith a**glob OOMs —resolveGlobreaddir-recurses the whole tree includingnode_modules#7363 —os test '**/*.test.json'OOMs;resolveGlobreaddir-recursesnode_modules.fieldpath is documented asbody.*, butHttpTestAdapterreturns the parsed body with nobodywrapper — every documented path resolves toundefined#7365 —TestAssertionSchema.fielddocumentsbody.*paths, butHttpTestAdapterreturns the parsed body with nobodywrapper, so every documented path resolves toundefined. That is thebody.data.itemsrow above; this PR makes it visible rather than causing it.Sibling-operator sweep
Same file, per the card.
containswas the only type-switched assertion, and the only one carrying this defect:equals,not_equalsis_null,not_nullcontainselsenot_contains,gt,gte,lt,lte,errorTestAssertionTypeSchema, no branch in the runnerdefault:— annoying but honest, not this defectThe six declared-but-unimplemented operators are left as they are: implementing them is a feature decision, not this cleanup. They are pinned in the new tests so a later implementation is deliberate rather than accidental.
Tests
New
packages/core/src/qa/runner.test.ts, 17 cases driven through the publicrunSuitesurface:undefinedat the leaf,undefinedfor the whole response,null, number, boolean, object) fails, and the message names the type and the right suspect;is_nullstill passing on a missing path.Reverse-verified: reverting
runner.tsalone fails 7 of the 17 and passes the other 10, so the behaviour pins constrain the fix without over-constraining the unchanged paths. Full@objectstack/coresuite green (744 tests / 30 files).Changeset
@objectstack/core: minor, read offscripts/check-changeset-no-major.mjs: every publishable package is in the Changesetsfixedgroup, so onemajorpromotes the whole ~70-package stack; during the launch window breaking changes ship asminorunder pre-1.0 semantics, and the gate enforces it. This is a breaking behaviour change of published@objectstack/core(vacuous green → red), so it is not apatch. No ADR-0087 disposition trio is required —check-adr-0087-registrationconfirms the diff adds no declared-breaking changeset, andpackages/specis untouched.Gates run locally
70 workflow gates enumerated fresh (64
check:*inlint.yml+ the 3 inspec-liveness-check.yml+check:console-sha,check:objectui-pin-fresh,check:adr-links). Run in the worktree on a fully built workspace:lint.ymlquality-gates batch (includinglint, all changeset gates,check:wildcard-fallthrough,check:published-files,check:type-check-coverage).@objectstack/specbatch (tsc --noEmit,check:generated,check:docs,check:liveness,check:api-surface,check:exported-any,check:dual-source-exports, …).check:type-check-debt, example apps, downstream-contract), plus@objectstack/driver-sqltests and@objectstack/spec analyze.Two gates went red on first run and both reproduce on an untouched worktree at the base commit
f3f855ac, so neither is this diff:check:i18n-coverage— a build-prerequisite gate; it needs the whole workspace built. Green afterpnpm build(12 configs, 660 baselined untranslated strings, none new).check:platform-checklist—coverage.json · qa: UNCLASSIFIED, because fix(spec,cli): govern the QA testing domain and enforceTestSuiteSchemaat theos testload site (#6247) #7255 seededpackages/spec/liveness/qa.jsonand the checklist has noqarow yet. Already filed as finding:check:platform-checklistis red onmain— the newqaliveness ledger is neither mapped nor waived in coverage.json #7347; the gate runs on a manual cadence, not in CI.