Skip to content

Commit 4ee7013

Browse files
committed
Add conformance tests
1 parent 4c15ffc commit 4ee7013

18 files changed

Lines changed: 1346 additions & 5 deletions

AGENTS.md

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,42 @@ it passes whether or not the `snapshot()` is there:
218218
- A subquery renders **bare `<div>`s** for its group header/body, not a `rule-group` element
219219
(React's `RuleWithSubQueryGroupComponentsWrapper`), and it is not customizable.
220220

221+
### The conformance harness (step 6)
222+
223+
`vitest.conformance.config.ts` runs **two projects**, because the two fixture layers demand
224+
opposite render modes. This is structural, not cosmetic — one plugin instance cannot serve both.
225+
226+
| Project | Compilation | Environment | Renders |
227+
| ----------------- | -------------------------------------- | ----------- | --------------------------------------------------------------------------------------- |
228+
| `conformance-ssr` | `generate: 'ssr'`, `hydratable: false` | `node` | `renderToString`, controlled `query` — the **static** layer |
229+
| `conformance-dom` | default `dom` | `jsdom` | testing-library + `flush()`, **uncontrolled** `defaultQuery` — the **post-flush** layer |
230+
231+
- **The static layer is rendered server-side deliberately.** The fixtures come from
232+
`renderToStaticMarkup` with no effects run; Solid's client `render()` runs effects, so the
233+
"extract before the scheduler flushes" trick both prior ports used is unavailable. SSR runs no
234+
effects at all, so this is an _exact_ match rather than an approximation — and it exercises the
235+
ssr path in all 50 cases for free.
236+
- ⚠️ **`solid({ ssr: true, … })` on the ssr project is load-bearing** and is _not_ redundant with
237+
`solid: { generate: 'ssr' }`. `vite-plugin-solid@3` injects a `browser` condition in test mode
238+
(`isTestMode && !options.ssr`), `@solidjs/web` lists `browser` before `node`, and the browser
239+
build's `renderToString` is a stub returning `undefined`. `ssr: true` suppresses the injection
240+
(and the plugin's forced `environment: 'jsdom'`); `options.solid` merges last, so the explicit
241+
`generate`/`hydratable` still win. Do **not** "fix" this by hand-writing `resolve.conditions`.
242+
- **`hydratable: false`** — hydration keys land as _attributes_ and would break byte-identical
243+
`class` comparison. (Marker comments are ignored by extraction; attributes are not.)
244+
- **`cases.ts` carries no rendering.** The render helpers are split into `render-ssr.tsx` and
245+
`render-dom.tsx` so the ssr project never imports `@solidjs/testing-library` and vice versa.
246+
- **`scenarios.tsx`, not `.ts`**`getValueEditorSeparator` returns JSX, which each project must
247+
compile for its own target.
248+
- ⚠️ **`actions.solid.test.ts` must `flush()` once after `createRoot` before replaying.** Effects
249+
created inside a root are queued, not run eagerly, so the controlled-`query` sync effect's first
250+
run otherwise lands on the flush _after_ the first op and silently reverts it. Found the hard
251+
way: 4 of 19 cases failed with the ops apparently never applied.
252+
- `extract.ts` exposes both `extractFromContainer` and `extractFromMarkup` (upstream's
253+
`schemaVersion` 2 split). The markup form builds its own `JSDOM` when there is no global
254+
`DOMParser`, which is what lets the ssr project run in the `node` environment and thereby prove
255+
a server render needs no document.
256+
221257
### Types
222258

223259
- **`jsxImportSource` is `"@solidjs/web"`.** `solid-js@2` owns no JSX namespace and no
@@ -241,12 +277,30 @@ it passes whether or not the `snapshot()` is there:
241277
**Standing rule: every gate must be proven to fail.** When a step adds a gate, deliberately break
242278
it, record that it went red, then revert. A gate that cannot fail is worse than none.
243279

244-
Current gates (step 8): `check:versions`, `fmt:check`, `build`, `check`, `check:exports`,
280+
Current gates (steps 6 + 8): `check:versions`, `fmt:check`, `build`, `check`, `check:exports`,
245281
`lint`, `test:coverage` (global 80% lines, plus a per-directory 90% lines on `packages/*/src/**`
246282
widened at step 5 from the step-3 `packages/*/src/reactive/**`, which it subsumes; both
247-
non-vacuous, both proved red with no injected dead code), `test:ssr` (**both halves**), and
248-
`check` including the examples. (`conformance` is a stub that exits 0 until step 6; it is not a
249-
gate yet.)
283+
non-vacuous, both proved red with no injected dead code), **`conformance`** (237 assertions: 50
284+
static classnames, 50 accessible descriptions, 50 post-flush classnames, 58 action sequences, 19
285+
port-side action sequences, plus alignment/drift/format), `test:ssr` (**both halves**), and
286+
`check` including the examples.
287+
288+
The four step-6 conformance gates were each proved red and reverted:
289+
290+
1. **DOM parity**` conformance-gate-probe` appended to `ActionElement.tsx`'s class turned
291+
exactly 100 cases red (50 static + 50 post-flush), which is the split the two projects promise.
292+
2. **`schemaVersion`**`EXPECTED_SCHEMA_VERSION = 3` made `conformance:fetch` exit 1 with the
293+
"update `test/conformance` before bumping the tag" message.
294+
3. **Scenario drift** — renaming the local `allControls` scenario turned the drift test (and the
295+
three case-alignment tests) red while all 50 rendered cases stayed green.
296+
4. **Value-editor reset** — an early `return` in `createValueEditorReset`'s apply phase left
297+
conformance at 237/237 green (as upstream predicts: every case is `differsFromStatic: false`)
298+
while turning 5 of the 9 post-mount unit assertions red. That asymmetry is exactly why the
299+
plan forbids proving this one through the post-flush fixture.
300+
301+
Separately confirmed: with `test/fixtures/` removed, `bun run test` still passes 284/284 and
302+
`conformance:test` fails with the actionable "run `bun run conformance:fetch`" message rather than
303+
an opaque parse error.
250304

251305
The step-8 example gate was proved red twice, independently, and reverted both times:
252306
`document.title` injected into `QueryBuilder.tsx` turned the served response into a 500 and took

packages/solid-querybuilder/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@
5959
"check:exports": "bun run ./scripts/check-dist-specifiers.ts && attw --pack . --profile esm-only",
6060
"check:versions": "bun run ./scripts/check-versions.ts",
6161
"test:ssr": "bun run ./scripts/ssr-smoke.ts",
62-
"conformance": "echo 'conformance harness lands at step 6' && exit 0"
62+
"conformance": "bun run conformance:fetch && bun run conformance:test",
63+
"conformance:fetch": "bun ./scripts/fetch-fixtures.ts",
64+
"conformance:test": "vitest run --config vitest.conformance.config.ts"
6365
},
6466
"peerDependencies": {
6567
"solid-js": "^2.0.0-beta.32",
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Downloads the conformance fixture set published by `react-querybuilder` as a release asset and
3+
* extracts it into `test/fixtures/` (gitignored).
4+
*
5+
* The fixtures are the contract this port is held to: the `class` attribute of every rendered
6+
* element, the accessible description of every rule group, and the result of every curated
7+
* mutation sequence. They are *not* generated here — regenerating them locally would make the
8+
* tests tautological.
9+
*
10+
* The pinned tag is a deliberate constant rather than "latest": a fixture set changes when
11+
* upstream rendering changes, and that should surface as a reviewed bump here, not as a
12+
* mysterious CI failure on an unrelated PR.
13+
*/
14+
15+
import { mkdir, rm } from 'node:fs/promises';
16+
import * as path from 'node:path';
17+
18+
/**
19+
* The upstream release whose fixtures this port is verified against. Bump deliberately, and
20+
* expect a diff in the conformance tests when you do.
21+
*/
22+
export const CONFORMANCE_TAG = 'v8.22.4';
23+
24+
/**
25+
* The fixture *shape* version. Not the tag: upstream may cut a dozen releases without changing
26+
* the schema, and a schema change must fail loudly rather than be mis-read.
27+
*/
28+
export const EXPECTED_SCHEMA_VERSION = 2;
29+
30+
const ASSET = 'rqb-conformance-fixtures.tar.gz';
31+
const RELEASE_URL = `https://github.com/react-querybuilder/react-querybuilder/releases/download/${CONFORMANCE_TAG}`;
32+
33+
export const fixturesDir: string = path.resolve(import.meta.dirname, '../test/fixtures');
34+
35+
const download = async (url: string): Promise<ArrayBuffer> => {
36+
const response = await fetch(url);
37+
if (!response.ok) {
38+
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
39+
}
40+
return response.arrayBuffer();
41+
};
42+
43+
/**
44+
* The tarball is not byte-reproducible across generation runs (BSD `tar` has no `--sort`/
45+
* `--mtime`), so the checksum is only meaningful against the sidecar published *with that
46+
* archive*. Both are therefore downloaded from the same release, and the check guards transport,
47+
* not provenance.
48+
*/
49+
const verifyChecksum = (archive: ArrayBuffer, sidecar: string) => {
50+
const expected = sidecar.trim().split(/\s+/)[0];
51+
const actual = new Bun.CryptoHasher('sha256').update(archive).digest('hex');
52+
if (actual !== expected) {
53+
throw new Error(`Checksum mismatch for ${ASSET}: expected ${expected}, got ${actual}`);
54+
}
55+
};
56+
57+
export const fetchFixtures = async (): Promise<void> => {
58+
const [archive, sidecar] = await Promise.all([
59+
download(`${RELEASE_URL}/${ASSET}`),
60+
download(`${RELEASE_URL}/${ASSET}.sha256`).then(b => new TextDecoder().decode(b)),
61+
]);
62+
63+
verifyChecksum(archive, sidecar);
64+
65+
// Wipe first, so a fixture file dropped upstream cannot linger locally and keep a stale test passing.
66+
await rm(fixturesDir, { recursive: true, force: true });
67+
await mkdir(fixturesDir, { recursive: true });
68+
69+
const archivePath = path.join(fixturesDir, ASSET);
70+
await Bun.write(archivePath, archive);
71+
72+
const untar = Bun.spawnSync(['tar', '-xzf', archivePath, '-C', fixturesDir]);
73+
if (untar.exitCode !== 0) {
74+
throw new Error(`tar failed: ${new TextDecoder().decode(untar.stderr)}`);
75+
}
76+
await rm(archivePath);
77+
78+
const index = await Bun.file(path.join(fixturesDir, 'index.json')).json();
79+
if (index.schemaVersion !== EXPECTED_SCHEMA_VERSION) {
80+
throw new Error(
81+
`Fixture schemaVersion is ${index.schemaVersion}, expected ${EXPECTED_SCHEMA_VERSION}. ` +
82+
`The fixture format changed upstream; update test/conformance before bumping the tag.`
83+
);
84+
}
85+
86+
console.log(
87+
`Fetched conformance fixtures from ${CONFORMANCE_TAG} ` +
88+
`(core ${index.generator.version}, schema ${index.schemaVersion}): ` +
89+
`${index.counts.renderedCases} rendered cases, ${index.counts.actionSequences} sequences.`
90+
);
91+
};
92+
93+
if (import.meta.main) {
94+
await fetchFixtures();
95+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* The `title` attribute of every rule group, for all 50 scenario × query pairs.
3+
*
4+
* This is where `accessibleDescriptionGenerator` surfaces. The `customized` scenario supplies a
5+
* non-default generator, so these assertions cover more than the identity function.
6+
*
7+
* Static layer only, rendered server-side like `classnames.test.ts`. Upstream deliberately
8+
* publishes no post-flush descriptions layer: `title` derives from `path` alone and never reads
9+
* `value`, so effects cannot change it.
10+
*/
11+
12+
import { describe, expect, it } from 'vitest';
13+
import { caseAlignment, loadFixture, renderPairs } from './cases.js';
14+
import type { AccessibleDescriptionEntry } from './extract.js';
15+
import { renderAndExtract } from './render-ssr.jsx';
16+
17+
interface AccessibleDescriptionsFixture {
18+
cases: {
19+
scenario: string;
20+
query: string;
21+
accessibleDescriptions: AccessibleDescriptionEntry[];
22+
}[];
23+
}
24+
25+
const fixture = await loadFixture<AccessibleDescriptionsFixture>('accessible-descriptions.json');
26+
27+
describe('conformance: accessible descriptions', () => {
28+
it('renders the same number of cases the fixture recorded, in the same order', () => {
29+
const { local, recorded } = caseAlignment(fixture.cases);
30+
expect(renderPairs).toHaveLength(fixture.cases.length);
31+
expect(local).toEqual(recorded);
32+
});
33+
34+
for (const [i, pair] of renderPairs.entries()) {
35+
const expected = fixture.cases[i];
36+
37+
it(`${expected.scenario} × ${expected.query}`, () => {
38+
const { accessibleDescriptions } = renderAndExtract(pair);
39+
40+
expect(accessibleDescriptions).toEqual(expected.accessibleDescriptions);
41+
});
42+
}
43+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* The port-side half of the action conformance suite: the guard-sensitive sequences replayed
3+
* through the manager `createQueryBuilderState` builds from `QueryBuilderProps`, rather than
4+
* through a manager configured directly.
5+
*
6+
* This is what catches an option-mapping bug — a `disabled` array that never reaches
7+
* `disabledPaths`, or a `maxLevels` that defaults wrong. Two narrowings relative to
8+
* `actions.test.ts`, both deliberate:
9+
*
10+
* - Only the resulting query is asserted. `QueryBuilderProps` has no `onInvalidTarget`, so abort
11+
* reasons are not observable here; a guard that fails to apply shows up anyway as a query that
12+
* changed when it should not have.
13+
* - `id`s are stripped before comparing. `createQueryBuilderState` seeds its manager through
14+
* `resolveCandidateQuery`, which draws from the injected generator, so generated `id`s are
15+
* offset by the seeding draws by a constant. That offset is an artifact of construction, not of
16+
* mutation.
17+
*
18+
* The state is built inside `createRoot(dispose => …)` — Solid's equivalent of Svelte's
19+
* `$effect.root` and Vue's `effectScope`. `createQueryBuilderState` registers an `onCleanup` for
20+
* its manager subscription and reads a context, both of which need an owner on the stack; a root
21+
* supplies one outside a component.
22+
*
23+
* ⚠️ The root body is an *owned* scope, so the replay itself runs outside it: a reactive write
24+
* made under an owner throws `REACTIVE_WRITE_IN_OWNED_SCOPE`. And `flush()` runs between ops,
25+
* because a plain read after a write still returns the old value until the next microtask.
26+
*/
27+
28+
import type { RuleGroupType, RuleGroupTypeAny } from '@react-querybuilder/core';
29+
import { formatQuery } from '@react-querybuilder/core';
30+
import { createRoot, flush } from 'solid-js';
31+
import { describe, expect, it } from 'vitest';
32+
import { createQueryBuilderState } from '../../src/reactive/index.js';
33+
import type { QueryBuilderProps } from '../../src/types/index.js';
34+
import { loadFixture } from './cases.js';
35+
import { createIdGenerator, queries, type QueryFixtureName } from './queries.js';
36+
import { applyOp, type ActionCase, type RunOptions } from './replay.js';
37+
38+
const fixture = await loadFixture<{ cases: ActionCase[] }>('actions.json');
39+
40+
const stripIDs = (query: RuleGroupTypeAny): unknown =>
41+
JSON.parse(formatQuery(query as RuleGroupType, 'json_without_ids'));
42+
43+
/** `respectDisabled: false` has no prop equivalent, so those cases are skipped. */
44+
const eligible = fixture.cases.filter(
45+
c =>
46+
c.options.respectDisabled !== false &&
47+
(c.options.disabledPaths !== undefined ||
48+
c.options.maxLevels !== undefined ||
49+
c.fixture === 'rootDisabled' ||
50+
c.fixture === 'withDisabled')
51+
);
52+
53+
const propsFor = (options: RunOptions): Partial<QueryBuilderProps> => ({
54+
...(options.queryDisabled ? { disabled: true } : {}),
55+
...(options.disabledPaths ? { disabled: options.disabledPaths } : {}),
56+
...(options.maxLevels === undefined ? {} : { maxLevels: options.maxLevels }),
57+
});
58+
59+
describe('conformance: actions through createQueryBuilderState', () => {
60+
it('has guard-sensitive cases to replay', () => {
61+
expect(eligible.length).toBeGreaterThan(5);
62+
});
63+
64+
for (const { name, fixture: fixtureName, ops, options, expected } of eligible) {
65+
it(name, () => {
66+
const root = createRoot(dispose => {
67+
const state = createQueryBuilderState({
68+
...propsFor(options),
69+
query: structuredClone(queries[fixtureName as QueryFixtureName]),
70+
idGenerator: createIdGenerator(),
71+
// The fixture corpus mixes `RuleGroupType` and `RuleGroupTypeIC`, which
72+
// `QueryBuilderProps` discriminates between. The cast collapses that here; the
73+
// discrimination itself is covered by `types.test-d.ts`.
74+
} as QueryBuilderProps);
75+
return { manager: state.manager, dispose };
76+
});
77+
78+
// Settle the mount before replaying. Effects created inside a root are *queued*, not run
79+
// eagerly, so the controlled-`query` sync effect's first run happens at the next `flush()`
80+
// — which, without this, would be the one after the first op, silently reverting it. A
81+
// mounted component has already had that flush before a user can touch anything.
82+
flush();
83+
84+
for (const op of ops) {
85+
applyOp(root.manager, op);
86+
flush();
87+
}
88+
89+
expect(stripIDs(root.manager.getQuery())).toEqual(stripIDs(expected.query));
90+
root.dispose();
91+
});
92+
}
93+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Replays all 58 curated mutation sequences through `QueryManager` and asserts the resulting
3+
* query, per-op abort reasons, and refusal flags against the recorded fixtures.
4+
*
5+
* This exercises core rather than the port. It is here anyway because it pins the core version
6+
* the port is built against to the behavior the fixtures describe: a silent behavior change in a
7+
* patch release fails here rather than in a consumer's app. The port-side half — that
8+
* `createQueryBuilderState` maps props onto the manager's guard options correctly — is in
9+
* `actions.solid.test.ts`.
10+
*/
11+
12+
import { QueryManager } from '@react-querybuilder/core';
13+
import { describe, expect, it } from 'vitest';
14+
import { loadFixture } from './cases.js';
15+
import { createIdGenerator, queries, type QueryFixtureName } from './queries.js';
16+
import { guardsOf, replay, type ActionCase } from './replay.js';
17+
18+
const fixture = await loadFixture<{ cases: ActionCase[] }>('actions.json');
19+
20+
describe('conformance: actions', () => {
21+
it('replays every recorded sequence', () => {
22+
expect(fixture.cases).toHaveLength(58);
23+
});
24+
25+
for (const { name, fixture: fixtureName, ops, options, expected } of fixture.cases) {
26+
it(name, () => {
27+
const result = replay(
28+
ops,
29+
onInvalidTarget =>
30+
new QueryManager(structuredClone(queries[fixtureName as QueryFixtureName]), {
31+
idGenerator: createIdGenerator(),
32+
...guardsOf(options),
33+
onInvalidTarget,
34+
})
35+
);
36+
37+
expect(result.query).toEqual(expected.query);
38+
expect(result.aborts).toEqual(expected.aborts);
39+
expect(result.allAborts).toEqual(expected.allAborts);
40+
expect(result.refused).toEqual(expected.refused);
41+
});
42+
}
43+
});

0 commit comments

Comments
 (0)