Skip to content

Commit 28d1eb7

Browse files
os-zhuangclaude[bot]claude
authored
fix(core): fail the QA contains assertion on a non-evaluable actual instead of silently passing (#7256) (#7348)
* fix(core): fail the QA `contains` assertion on a non-evaluable actual 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 * docs(cli): say that an assertion `os test` cannot evaluate fails rather 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 --------- Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d0d5205 commit 28d1eb7

4 files changed

Lines changed: 338 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
fix(core): the QA `contains` assertion fails loudly instead of silently passing on a non-array/non-string actual (#7256)
6+
7+
`TestRunner.assert`'s `case 'contains':` handled the two shapes it can evaluate —
8+
an array (membership) and a string (substring) — and had **no `else`**. Every
9+
other shape fell straight out of the switch throwing nothing, so the assertion
10+
reported **PASSED**. A scenario asserting
11+
`{ field: "body.data.items", operator: "contains", expectedValue: "acme" }`
12+
against a response that has no `body.data.items` at all reported ✅. The
13+
overwhelmingly common way to reach that branch is the one that matters most: a
14+
typo'd `field` path, or a response shape that moved under a suite nobody
15+
re-read. The assertion that was supposed to *be* the test is the thing that
16+
silently disappears, and CI believes the green.
17+
18+
`contains` was the only path in this engine that could decide "no comparison
19+
applies here" and report success. Every other unhandled shape already fails
20+
loud — an operator with no branch throws `Unknown assertion operator`, an action
21+
type with no adapter branch throws `Unsupported action type in HttpAdapter`,
22+
and `equals`/`not_equals`/`is_null`/`not_null` all compare unconditionally. This
23+
closes the asymmetry rather than adding a new posture: an assertion the engine
24+
**cannot evaluate** is a **failed** assertion.
25+
26+
The message is written for the author who has to act on it, so it names the
27+
field, the operator and the runtime type of what the path actually resolved to
28+
(`null` and arrays get their own names, not `typeof`'s `object`), and then says
29+
which of the two things is wrong:
30+
31+
```
32+
Assertion failed: body.data.items cannot be evaluated by 'contains' — expected an
33+
array or a string at that path, got undefined. The path resolved to nothing — the
34+
field is absent from the result, or the path is misspelled. Use 'is_null' if
35+
asserting absence is what you meant.
36+
```
37+
38+
`undefined`/`null` point at the **fixture** (the path did not resolve, so the
39+
field path or the response shape it was written against is the suspect);
40+
a number, boolean or object points at the **assertion** (the path resolved
41+
fine and `contains` is the wrong operator for what it found).
42+
43+
**Behaviour change, and its measured blast radius.** Suites that today pass a
44+
`contains` against a non-array/non-string will start failing — which is the
45+
point; each such assertion was asserting nothing. The in-tree radius was
46+
measured on the loud build and is **zero**: `os test` is the runner's only
47+
consumer, and the repository contains no Quality Protocol suite documents at
48+
all (no `qa/*.test.json` anywhere; the three example apps run `vitest`, and
49+
`packages/qa/*` are vitest suites that never touch `TestRunner`). No CI workflow
50+
invokes `os test`. So no in-repo case was passing vacuously and none needed
51+
repair. Downstream suites are the ones that will see red, and every case they
52+
see is a test that was never running.
53+
54+
The two evaluable shapes are untouched in both directions: a matching array or
55+
string still passes, a non-matching one still fails with its existing message.
56+
`not_contains`, `gt`, `gte`, `lt`, `lte` and `error` are declared in
57+
`TestAssertionTypeSchema` and still have no branch in the runner — they were
58+
already refused loudly at `default:` rather than silently passed, so they do not
59+
carry this defect; that gap is recorded separately and is pinned here so a later
60+
implementation is a deliberate change rather than an accident.

content/docs/deployment/cli.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,15 @@ and counts as one failed suite — the rest of the glob still runs. This is what
10261026
stops a malformed suite from reporting success: a misspelled `steps` key used to
10271027
produce a scenario that passed having executed nothing.
10281028

1029+
An assertion the runner **cannot evaluate** fails, it does not pass. `contains`
1030+
is defined over an array (membership) and a string (substring); point it at
1031+
anything else — most often a `field` path the response does not carry, because it
1032+
was misspelled or the shape moved — and it fails, naming the field, the operator
1033+
and the runtime type it actually found. Until #7256 that case fell out of the
1034+
switch and reported ✅, so a `contains` against a missing path was a test that
1035+
silently deleted itself. Assert absence with `is_null`; compare a scalar with
1036+
`equals`.
1037+
10291038
#### `os doctor`
10301039

10311040
Checks your development environment and reports issues:
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #7256 — the `contains` assertion used to SILENTLY PASS when the actual value was
4+
// neither an array nor a string. The `case 'contains':` block handled the two
5+
// evaluable shapes and had no `else`, so `undefined` (a typo'd `field` path, or a
6+
// response shape that moved), `null`, a number or an object fell out of the switch
7+
// throwing nothing, and the scenario reported ✅. A suite asserting `contains`
8+
// against a field the result does not have was asserting NOTHING, and CI believed it.
9+
//
10+
// These pin both halves of the fix: the three non-evaluable shapes now fail LOUD with
11+
// a message that names the runtime type and says which of the fixture or the assertion
12+
// is the suspect, and the two evaluable shapes keep their pre-existing behaviour in
13+
// BOTH directions (a match still passes, a miss still fails).
14+
15+
import { describe, it, expect } from 'vitest';
16+
import * as QA from '@objectstack/spec/qa';
17+
import { TestRunner } from './runner.js';
18+
import type { TestExecutionAdapter } from './adapter.js';
19+
20+
/** An adapter that hands the runner one fixed result — the assertion is the unit under test. */
21+
class StubAdapter implements TestExecutionAdapter {
22+
constructor(private result: unknown) {}
23+
async execute(): Promise<unknown> {
24+
return this.result;
25+
}
26+
}
27+
28+
/** Run one assertion against one canned adapter result, through the public runner surface. */
29+
async function runAssertion(
30+
result: unknown,
31+
assertion: QA.TestAssertion,
32+
): Promise<{ passed: boolean; error: string }> {
33+
const runner = new TestRunner(new StubAdapter(result));
34+
const [outcome] = await runner.runSuite({
35+
name: 'contains-pins',
36+
scenarios: [
37+
{
38+
id: 'scenario-1',
39+
name: 'single assertion',
40+
steps: [
41+
{
42+
name: 'step-1',
43+
action: { type: 'api_call', target: '/api/v1/accounts' },
44+
assertions: [assertion],
45+
},
46+
],
47+
},
48+
],
49+
});
50+
const error = outcome.error;
51+
return {
52+
passed: outcome.passed,
53+
error: error instanceof Error ? error.message : String(error ?? ''),
54+
};
55+
}
56+
57+
const containsAcme: QA.TestAssertion = {
58+
field: 'body.data.items',
59+
operator: 'contains',
60+
expectedValue: 'acme',
61+
};
62+
63+
describe("TestRunner — `contains` against a non-array/non-string actual fails loud (#7256)", () => {
64+
it('fails when the field path resolves to nothing (the filed case: a missing path)', async () => {
65+
const { passed, error } = await runAssertion({ body: { data: {} } }, containsAcme);
66+
67+
expect(passed).toBe(false);
68+
// Names the field, the operator and the runtime type...
69+
expect(error).toContain('body.data.items');
70+
expect(error).toContain("'contains'");
71+
expect(error).toContain('got undefined');
72+
// ...and points at the FIXTURE, because the path is what did not resolve.
73+
expect(error).toContain('absent from the result');
74+
});
75+
76+
it('fails when the whole response shape is missing, not just the leaf', async () => {
77+
const { passed, error } = await runAssertion(undefined, containsAcme);
78+
79+
expect(passed).toBe(false);
80+
expect(error).toContain('got undefined');
81+
});
82+
83+
it('fails when the field path resolves to null', async () => {
84+
const { passed, error } = await runAssertion({ body: { data: { items: null } } }, containsAcme);
85+
86+
expect(passed).toBe(false);
87+
expect(error).toContain('got null');
88+
// `null` is not reported as `object` — the author needs to see which one it is.
89+
expect(error).not.toContain('got object');
90+
expect(error).toContain('resolved to null');
91+
});
92+
93+
it('fails when the field path resolves to a number', async () => {
94+
const { passed, error } = await runAssertion({ body: { data: { items: 42 } } }, containsAcme);
95+
96+
expect(passed).toBe(false);
97+
expect(error).toContain('got number');
98+
// The path resolved fine here, so the ASSERTION is the suspect, not the fixture.
99+
expect(error).toContain('array membership and string substrings only');
100+
});
101+
102+
it('fails when the field path resolves to an object', async () => {
103+
const { passed, error } = await runAssertion(
104+
{ body: { data: { items: { acme: true } } } },
105+
containsAcme,
106+
);
107+
108+
expect(passed).toBe(false);
109+
expect(error).toContain('got object');
110+
expect(error).toContain('array membership and string substrings only');
111+
});
112+
113+
it('fails when the field path resolves to a boolean', async () => {
114+
const { passed, error } = await runAssertion({ body: { data: { items: false } } }, containsAcme);
115+
116+
expect(passed).toBe(false);
117+
expect(error).toContain('got boolean');
118+
});
119+
120+
it('every non-evaluable shape reports the same failure, not a pass', async () => {
121+
const nonEvaluable: unknown[] = [undefined, null, 0, 42, false, true, { acme: true }];
122+
123+
for (const value of nonEvaluable) {
124+
const { passed, error } = await runAssertion({ body: { data: { items: value } } }, containsAcme);
125+
expect(passed, `contains against ${String(value)} must not pass`).toBe(false);
126+
expect(error).toContain("cannot be evaluated by 'contains'");
127+
}
128+
});
129+
});
130+
131+
describe('TestRunner — `contains` keeps its behaviour on the two evaluable shapes (#7256)', () => {
132+
it('passes when the array contains the expected member', async () => {
133+
const { passed } = await runAssertion({ body: { data: { items: ['acme', 'globex'] } } }, containsAcme);
134+
135+
expect(passed).toBe(true);
136+
});
137+
138+
it('fails when the array does not contain the expected member', async () => {
139+
const { passed, error } = await runAssertion({ body: { data: { items: ['globex'] } } }, containsAcme);
140+
141+
expect(passed).toBe(false);
142+
expect(error).toContain('array does not contain acme');
143+
// Still the membership failure, NOT the new inapplicable-shape failure.
144+
expect(error).not.toContain("cannot be evaluated by 'contains'");
145+
});
146+
147+
it('passes when the string contains the expected substring', async () => {
148+
const { passed } = await runAssertion({ body: { data: { items: 'acme corp' } } }, containsAcme);
149+
150+
expect(passed).toBe(true);
151+
});
152+
153+
it('fails when the string does not contain the expected substring', async () => {
154+
const { passed, error } = await runAssertion({ body: { data: { items: 'globex corp' } } }, containsAcme);
155+
156+
expect(passed).toBe(false);
157+
expect(error).toContain('string does not contain acme');
158+
expect(error).not.toContain("cannot be evaluated by 'contains'");
159+
});
160+
161+
it('an empty array is evaluable — it simply does not contain the member', async () => {
162+
const { passed, error } = await runAssertion({ body: { data: { items: [] } } }, containsAcme);
163+
164+
expect(passed).toBe(false);
165+
expect(error).toContain('array does not contain acme');
166+
});
167+
168+
it('an empty string is evaluable — every string contains the empty substring', async () => {
169+
const { passed } = await runAssertion(
170+
{ body: { data: { items: '' } } },
171+
{ field: 'body.data.items', operator: 'contains', expectedValue: '' },
172+
);
173+
174+
expect(passed).toBe(true);
175+
});
176+
});
177+
178+
describe('TestRunner — the sibling operators are unchanged by #7256', () => {
179+
it('`equals` still compares unconditionally, including against a missing path', async () => {
180+
const { passed, error } = await runAssertion(
181+
{ body: {} },
182+
{ field: 'body.status', operator: 'equals', expectedValue: 'active' },
183+
);
184+
185+
expect(passed).toBe(false);
186+
expect(error).toContain('expected active');
187+
});
188+
189+
it('`is_null` still passes on a missing path — absence is what it asserts', async () => {
190+
const { passed } = await runAssertion(
191+
{ body: {} },
192+
{ field: 'body.status', operator: 'is_null', expectedValue: null },
193+
);
194+
195+
expect(passed).toBe(true);
196+
});
197+
198+
it('`not_null` still fails on a missing path', async () => {
199+
const { passed } = await runAssertion(
200+
{ body: {} },
201+
{ field: 'body.status', operator: 'not_null', expectedValue: null },
202+
);
203+
204+
expect(passed).toBe(false);
205+
});
206+
207+
// `not_contains` / `gt` / `gte` / `lt` / `lte` / `error` are declared in
208+
// `TestAssertionTypeSchema` and have no branch in the runner. That is a DIFFERENT
209+
// defect from #7256 (a declared operator the engine refuses is annoying but honest,
210+
// where a silent pass is a lie), and it is pinned here so a later implementation is
211+
// a deliberate change rather than an accident.
212+
it('a declared-but-unimplemented operator is refused, not silently passed', async () => {
213+
const { passed, error } = await runAssertion(
214+
{ body: { data: { items: ['globex'] } } },
215+
{ field: 'body.data.items', operator: 'not_contains', expectedValue: 'acme' },
216+
);
217+
218+
expect(passed).toBe(false);
219+
expect(error).toContain('Unknown assertion operator: not_contains');
220+
});
221+
});

packages/core/src/qa/runner.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,40 @@ export interface StepResult {
1919
duration: number;
2020
}
2121

22+
/**
23+
* Name the runtime shape of a value the way a suite author sees it in their fixture.
24+
* `typeof` answers `object` for both `null` and an array, which are the two shapes a
25+
* `contains` author most needs told apart from a plain record.
26+
*/
27+
function describeActualType(value: unknown): string {
28+
if (value === null) return 'null';
29+
if (Array.isArray(value)) return 'array';
30+
return typeof value;
31+
}
32+
33+
/**
34+
* Say WHICH of the two things is wrong, because the message is the only thing the
35+
* author has: `undefined`/`null` mean the path did not resolve to a value, so the
36+
* FIXTURE (the field path, or the response shape it was written against) is the
37+
* suspect; anything else means the path resolved fine and the ASSERTION picked an
38+
* operator that does not apply to what it found.
39+
*/
40+
function containsInapplicableHint(actual: unknown): string {
41+
if (actual === undefined) {
42+
return (
43+
'The path resolved to nothing — the field is absent from the result, or the path is misspelled. ' +
44+
"Use 'is_null' if asserting absence is what you meant."
45+
);
46+
}
47+
if (actual === null) {
48+
return "The path resolved to null. Use 'is_null' if asserting absence is what you meant.";
49+
}
50+
return (
51+
"'contains' tests array membership and string substrings only. " +
52+
"Use 'equals' to compare a scalar, or point the field at the array or string you meant to look inside."
53+
);
54+
}
55+
2256
export class TestRunner {
2357
constructor(private adapter: TestExecutionAdapter) {}
2458

@@ -173,6 +207,20 @@ export class TestRunner {
173207
if (!actual.includes(expected)) throw new Error(`Assertion failed: ${assertion.field} array does not contain ${expected}`);
174208
} else if (typeof actual === 'string') {
175209
if (!actual.includes(String(expected))) throw new Error(`Assertion failed: ${assertion.field} string does not contain ${expected}`);
210+
} else {
211+
// `contains` is defined over arrays (membership) and strings (substring), and
212+
// over nothing else. This branch used to be absent, so every other shape fell
213+
// out of the switch and the assertion reported PASSED (#7256) — a `contains`
214+
// written against a path the result does not carry was the test silently
215+
// deleting itself, and CI believed the green. An assertion the engine cannot
216+
// evaluate is a FAILED assertion, which is the posture every other unhandled
217+
// shape in this engine already takes (`default:` below; the HTTP adapter's
218+
// unknown action type).
219+
throw new Error(
220+
`Assertion failed: ${assertion.field} cannot be evaluated by 'contains' — ` +
221+
`expected an array or a string at that path, got ${describeActualType(actual)}. ` +
222+
containsInapplicableHint(actual)
223+
);
176224
}
177225
break;
178226
case 'not_null':

0 commit comments

Comments
 (0)