From 6c6bf22f520154f19e0ae330df8aedd39ac236f0 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 18 Aug 2026 11:14:41 -0400 Subject: [PATCH 1/3] Cover BXL indexing correctness and node isomorphism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a host integration suite for the indexing dimension of BXL computeds — that they land in the search doc in a shape the query engine matches and sorts on, and that they recompute when an edit invalidates the card — and a realm-server node smoke suite proving the package's raw-.ts sources and dynamic-import formula chunks load and evaluate under node's native type stripping. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yaml | 2 + .../host/tests/helpers/cards/bxl-tracking.ts | 11 +- .../tests/integration/bxl-indexing-test.gts | 393 ++++++++++++++++++ packages/realm-server/package.json | 1 + .../realm-server/tests/bxl-node-smoke-test.ts | 136 ++++++ packages/realm-server/tests/index.ts | 1 + packages/realm-server/tsconfig.json | 1 + pnpm-lock.yaml | 3 + 8 files changed, 544 insertions(+), 4 deletions(-) create mode 100644 packages/host/tests/integration/bxl-indexing-test.gts create mode 100644 packages/realm-server/tests/bxl-node-smoke-test.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ae6ff4b60d0..635915a3968 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -75,6 +75,7 @@ jobs: - 'packages/base/**' - 'packages/boxel-icons/**' - 'packages/boxel-ui/**' + - 'packages/bxl/**' - 'packages/host/**' - 'packages/realm-server/**' - 'packages/test-realm-cards/**' @@ -119,6 +120,7 @@ jobs: - 'packages/billing/**' - 'packages/boxel-icons/**' - 'packages/boxel-ui/**' + - 'packages/bxl/**' - 'packages/host/**' - 'packages/eslint-plugin-boxel/**' - 'packages/postgres/**' diff --git a/packages/host/tests/helpers/cards/bxl-tracking.ts b/packages/host/tests/helpers/cards/bxl-tracking.ts index 074f283e75c..ae2367a1549 100644 --- a/packages/host/tests/helpers/cards/bxl-tracking.ts +++ b/packages/host/tests/helpers/cards/bxl-tracking.ts @@ -255,8 +255,11 @@ export const bxlTrackingCardSource = ` // realm's first-ever index pass the live index is empty, so POL-100's // claims aggregations bake in their empty-set values; the next visit of // the policy converges them. Tests that assert converged aggregations -// re-write POL-100 with `bxlTrackingPol100Renewal` to trigger that visit. -function pol100Doc(policyStatus: string) { +// re-write POL-100 with `bxlTrackingPol100Renewal` to trigger that visit; +// a test needing more than one such visit builds its own docs with +// `bxlTrackingPol100Doc`, since a re-write of identical content is a +// no-op. +export function bxlTrackingPol100Doc(policyStatus: string) { return { data: { type: 'card', @@ -277,7 +280,7 @@ function pol100Doc(policyStatus: string) { }; } -export const bxlTrackingPol100Renewal = pol100Doc('Renewed'); +export const bxlTrackingPol100Renewal = bxlTrackingPol100Doc('Renewed'); export const bxlTrackingRealmContents: Record< string, @@ -364,7 +367,7 @@ export const bxlTrackingRealmContents: Record< }, }, }, - 'Policy/pol-100.json': pol100Doc('Active'), + 'Policy/pol-100.json': bxlTrackingPol100Doc('Active'), 'Policy/pol-200.json': { data: { type: 'card', diff --git a/packages/host/tests/integration/bxl-indexing-test.gts b/packages/host/tests/integration/bxl-indexing-test.gts new file mode 100644 index 00000000000..8b52904de5b --- /dev/null +++ b/packages/host/tests/integration/bxl-indexing-test.gts @@ -0,0 +1,393 @@ +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { rri } from '@cardstack/runtime-common'; +import type { IndexedInstance, Realm, Query } from '@cardstack/runtime-common'; +import type { Loader } from '@cardstack/runtime-common/loader'; + +import { + testRealmURL, + setupCardLogs, + setupLocalIndexing, + setupIntegrationTestRealm, +} from '../helpers'; +import { setupBaseRealm } from '../helpers/base-realm'; +import { + bxlTrackingCardSource, + bxlTrackingPol100Doc, + bxlTrackingPol100Renewal, + bxlTrackingRealmContents, +} from '../helpers/cards/bxl-tracking'; +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { searchCardsForTest } from '../helpers/search-cards'; +import { setupRenderingTest } from '../helpers/setup'; + +// BXL's `derive` profile runs at index time, so the search doc is where its +// correctness is load-bearing: it is what queries filter and sort on, and it +// is regenerated from scratch on every pass that touches the card. This +// suite covers that indexing dimension — BXL computeds land in the search +// doc in a shape the query engine can match on, and they recompute whenever +// an edit invalidates the card, whether the edit lands on the card itself, +// on a card it reaches through a link, or on the module that declares the +// formula. +// +// Per-function values are pinned by the expression suite and the cycle +// contract by the cyclic-graph suite; both read the same tracking-realm +// fixture, so an assertion here is about when a value is recomputed rather +// than what the formula returns. +module('Integration | bxl indexing', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + let loader: Loader; + let realm: Realm; + + setupLocalIndexing(hooks); + setupCardLogs( + hooks, + async () => await loader.import('@cardstack/base/card-api'), + ); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + autostart: true, + }); + + hooks.beforeEach(async function () { + loader = getService('loader-service').loader; + ({ realm } = await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: bxlTrackingRealmContents, + })); + // Query-backed inverses resolve against the live index at visit time, + // and the from-scratch pass above ran with an empty live index — the + // claims aggregations baked in their empty-set values. Re-visiting the + // policy converges them; every assertion below starts from that + // converged state. + await realm.write( + 'Policy/pol-100.json', + JSON.stringify(bxlTrackingPol100Renewal), + ); + }); + + async function indexedSearchDoc(id: string) { + let entry = await realm.realmIndexQueryEngine.instance(new URL(id)); + if (!entry || entry.type === 'instance-error') { + throw new Error( + `expected ${id} to index cleanly, got ${JSON.stringify(entry?.error)}`, + ); + } + return (entry as IndexedInstance).searchDoc ?? {}; + } + + async function matchingIds(query: Query) { + let { data } = await searchCardsForTest(realm.realmIndexQueryEngine, query); + return data.map((resource) => String(resource.id)); + } + + const policyRef = { module: rri(`${testRealmURL}tracking`), name: 'Policy' }; + const claimRef = { module: rri(`${testRealmURL}tracking`), name: 'Claim' }; + + async function writeClaim(path: string, attributes: Record) { + await realm.write( + path, + JSON.stringify({ + data: { + type: 'card', + attributes, + relationships: { + policy: { links: { self: '../Policy/pol-100' } }, + }, + meta: { adoptsFrom: { module: '../tracking', name: 'Claim' } }, + }, + }), + ); + } + + // A write of byte-identical content is skipped, so each revisit needs a + // distinguishable policy status. + async function revisitPolicy(policyStatus: string) { + await realm.write( + 'Policy/pol-100.json', + JSON.stringify(bxlTrackingPol100Doc(policyStatus)), + ); + } + + // =========================================================================== + // The search doc the query engine sees + // =========================================================================== + + test('BXL computeds are matchable by the query engine', async function (assert) { + // A computed that only rendered correctly would still be invisible to + // search. Matching on one proves the indexer wrote the computed value + // into the search doc under its field name, typed the way the field + // declares it — a string field matched by equality, a number field by + // range. + assert.deepEqual( + await matchingIds({ + filter: { on: claimRef, eq: { severityBand: 'Standard' } }, + }), + [`${testRealmURL}Claim/clm-1`], + 'a string computed matches by equality', + ); + assert.deepEqual( + await matchingIds({ + filter: { on: claimRef, range: { incurredAmount: { gt: 1000 } } }, + sort: [{ on: claimRef, by: 'claimId', direction: 'asc' }], + }), + [`${testRealmURL}Claim/clm-1`], + 'a number computed matches by range, so it indexed as a number', + ); + // POL-200 has no claims, so its loss ratio is 0 and it stays out. + assert.deepEqual( + await matchingIds({ + filter: { on: policyRef, range: { lossRatio: { gt: 0.4 } } }, + }), + [`${testRealmURL}Policy/pol-100`], + 'a computed chained off other computeds is matchable too', + ); + }); + + test('{ as: FieldDef } computeds are matchable on their nested paths', async function (assert) { + // The materialized field instance has to survive serialization into the + // search doc as a nested object, not as an opaque blob, or the dotted + // path has nothing to match against. Both policies band as Low — POL-200 + // has no claims at all, so its loss ratio is 0 — and the score field + // separates them. + assert.deepEqual( + await matchingIds({ + filter: { on: policyRef, eq: { 'riskBand.label': 'Low' } }, + sort: [{ on: policyRef, by: 'policyId', direction: 'asc' }], + }), + [`${testRealmURL}Policy/pol-100`, `${testRealmURL}Policy/pol-200`], + 'the nested label of a single materialized instance', + ); + assert.deepEqual( + await matchingIds({ + filter: { on: policyRef, range: { 'riskBand.score': { gt: 1 } } }, + }), + [`${testRealmURL}Policy/pol-100`], + 'a nested number keeps its type through materialization', + ); + assert.deepEqual( + await matchingIds({ + filter: { on: policyRef, eq: { 'claimBands.label': 'Minor' } }, + }), + [`${testRealmURL}Policy/pol-100`], + 'an element of a materialized array', + ); + }); + + test('BXL computeds are sortable', async function (assert) { + assert.deepEqual( + await matchingIds({ + filter: { type: policyRef }, + sort: [{ on: policyRef, by: 'premiumWithTax', direction: 'desc' }], + }), + [`${testRealmURL}Policy/pol-100`, `${testRealmURL}Policy/pol-200`], + 'descending by a computed premium', + ); + assert.deepEqual( + await matchingIds({ + filter: { type: policyRef }, + sort: [{ on: policyRef, by: 'premiumWithTax', direction: 'asc' }], + }), + [`${testRealmURL}Policy/pol-200`, `${testRealmURL}Policy/pol-100`], + 'and ascending, so the order tracks the values rather than the ids', + ); + }); + + // =========================================================================== + // Incremental reindex + // =========================================================================== + + test("editing a card's own input recomputes its computeds", async function (assert) { + await writeClaim('Claim/clm-1.json', { + claimId: 'CLM-1', + claimStatus: 'Open', + paidAmount: 20000, + reserveAmount: 1500, + }); + + let searchDoc = await indexedSearchDoc(`${testRealmURL}Claim/clm-1`); + assert.strictEqual(searchDoc.incurredAmount, 21500); + assert.strictEqual( + searchDoc.severityBand, + 'Large', + 'the band follows the new amount across its threshold', + ); + // The stale value is gone from the index, not merely shadowed by a + // fresh one — the previous band no longer matches anything. + assert.deepEqual( + await matchingIds({ + filter: { on: claimRef, eq: { severityBand: 'Standard' } }, + }), + [], + 'the superseded value is out of the search doc', + ); + }); + + test('editing a linked card recomputes the cards that traverse to it', async function (assert) { + await realm.write( + 'Customer/acme.json', + JSON.stringify({ + data: { + type: 'card', + attributes: { name: 'Acme Logistics', tier: 'Platinum' }, + meta: { adoptsFrom: { module: '../tracking', name: 'Customer' } }, + }, + }), + ); + + let policyDoc = await indexedSearchDoc(`${testRealmURL}Policy/pol-100`); + assert.strictEqual( + policyDoc.customerName, + 'Acme Logistics', + 'the policy reads the renamed customer one hop away', + ); + let claimDoc = await indexedSearchDoc(`${testRealmURL}Claim/clm-1`); + assert.strictEqual( + claimDoc.customerName, + 'Acme Logistics', + 'and the claim reads it two hops away, through the policy', + ); + }); + + test("an edit to a claim converges into the aggregate on the policy's next visit", async function (assert) { + await writeClaim('Claim/clm-2.json', { + claimId: 'CLM-2', + claimStatus: 'Open', + paidAmount: 1000, + reserveAmount: 500, + }); + + // The only stored edge runs claim → policy; the policy's `claims` side + // is a query resolved against the live index when the policy is + // visited. Writing the claim invalidates the claim, so the policy still + // carries the aggregate it computed on its last visit. + let searchDoc = await indexedSearchDoc(`${testRealmURL}Policy/pol-100`); + assert.strictEqual(searchDoc.paidClaimsTotal, 3980.75); + assert.strictEqual(searchDoc.openClaimCount, 1); + + // That next visit recomputes against the now-current claims. + await revisitPolicy('Reviewed'); + + searchDoc = await indexedSearchDoc(`${testRealmURL}Policy/pol-100`); + assert.strictEqual(searchDoc.paidClaimsTotal, 4200.5); + assert.strictEqual(searchDoc.reservedClaimsTotal, 2000); + assert.strictEqual( + searchDoc.openClaimCount, + 2, + 'the reopened claim counts toward the open tally', + ); + assert.strictEqual( + searchDoc.lossRatio, + 0.5167, + 'the chained computed follows the aggregate it reads', + ); + }); + + test('a claim joining or leaving the realm converges into the aggregate', async function (assert) { + await writeClaim('Claim/clm-4.json', { + claimId: 'CLM-4', + claimStatus: 'Open', + paidAmount: 19.25, + reserveAmount: 0, + }); + await revisitPolicy('Reviewed'); + + let searchDoc = await indexedSearchDoc(`${testRealmURL}Policy/pol-100`); + assert.strictEqual(searchDoc.paidClaimsTotal, 4000); + assert.strictEqual(searchDoc.openClaimCount, 2); + assert.deepEqual( + searchDoc.claimPolicyIds, + Array(3).fill(`${testRealmURL}Policy/pol-100`), + 'the new claim joins the inverse the cycle-walking formula reads', + ); + + await realm.delete('Claim/clm-4.json'); + await revisitPolicy('Audited'); + + searchDoc = await indexedSearchDoc(`${testRealmURL}Policy/pol-100`); + assert.strictEqual(searchDoc.paidClaimsTotal, 3980.75); + assert.strictEqual(searchDoc.openClaimCount, 1); + assert.deepEqual( + searchDoc.claimPolicyIds, + Array(2).fill(`${testRealmURL}Policy/pol-100`), + 'and leaves it again when the claim is deleted', + ); + }); + + test('editing the formula in the module recomputes every instance', async function (assert) { + // Formulas live in card source, so a formula change arrives as a module + // edit. Every instance of the card has to be revisited, not just the one + // that happens to be open. + await realm.write( + 'tracking.gts', + bxlTrackingCardSource.replace( + '.annualPremium * 1.07', + '.annualPremium * 1.1', + ), + ); + + assert.strictEqual( + (await indexedSearchDoc(`${testRealmURL}Policy/pol-100`)).premiumWithTax, + 13200, + ); + assert.strictEqual( + (await indexedSearchDoc(`${testRealmURL}Policy/pol-200`)).premiumWithTax, + 8800, + 'the instance nothing else touched is reindexed too', + ); + }); + + test("an edit that strips a card's inputs still yields a clean index entry", async function (assert) { + // Every computed on the policy now reads a missing link or a blank + // number, and one of them divides by a literal zero. An Excel error + // escaping as a thrown exception would turn the whole card into an + // instance-error entry and strand the computeds that are still + // perfectly well defined. + await realm.write( + 'Policy/pol-100.json', + JSON.stringify({ + data: { + type: 'card', + attributes: { policyId: 'POL-100', policyStatus: 'Lapsed' }, + meta: { adoptsFrom: { module: '../tracking', name: 'Policy' } }, + }, + }), + ); + + let entry = await realm.realmIndexQueryEngine.instance( + new URL(`${testRealmURL}Policy/pol-100`), + ); + assert.strictEqual( + entry?.type, + 'instance', + 'a clean instance entry, not an instance-error', + ); + let searchDoc = (entry as IndexedInstance).searchDoc ?? {}; + let customerName = searchDoc.customerName ?? null; + assert.strictEqual( + customerName, + null, + 'the dropped link recomputes to null rather than keeping a stale name', + ); + let divByZero = searchDoc.divByZero ?? null; + assert.strictEqual(divByZero, null, 'the #DIV/0! sentinel clips to null'); + assert.strictEqual( + searchDoc.premiumWithTax, + 0, + 'the blank premium reads as 0 under Excel blank semantics', + ); + + // The degraded card also leaves the result sets it used to match, so a + // query never serves the values it computed before the edit. + assert.deepEqual( + await matchingIds({ + filter: { on: policyRef, range: { 'riskBand.score': { gt: 1 } } }, + }), + [], + ); + }); +}); diff --git a/packages/realm-server/package.json b/packages/realm-server/package.json index 123d46e5598..f76a0ac943b 100644 --- a/packages/realm-server/package.json +++ b/packages/realm-server/package.json @@ -9,6 +9,7 @@ "@cardstack/billing": "workspace:*", "@cardstack/boxel-icons": "workspace:*", "@cardstack/boxel-ui": "workspace:*", + "@cardstack/bxl": "workspace:*", "@cardstack/eslint-plugin-boxel": "workspace:*", "@cardstack/local-types": "workspace:*", "@cardstack/postgres": "workspace:*", diff --git a/packages/realm-server/tests/bxl-node-smoke-test.ts b/packages/realm-server/tests/bxl-node-smoke-test.ts new file mode 100644 index 00000000000..c90b784dfff --- /dev/null +++ b/packages/realm-server/tests/bxl-node-smoke-test.ts @@ -0,0 +1,136 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import { basename } from 'path'; +import { + compileBxl, + evaluateBxl, + loadAllFormulaExtensions, + runNativeJqAsync, + BXL_BUILD_INFO, +} from '@cardstack/bxl'; + +// BXL is an isomorphic package: the host bundles it through Vite and serves +// it to card code, while platform code reaches it from node. Cards can't run +// in node, so node-side coverage is deliberately a smoke suite over plain +// JS objects — enough to prove the runtime is the same one the browser gets, +// not a second implementation. +// +// What only node can break is the delivery: the package ships raw `.ts` with +// `.ts` import specifiers and pulls its heavy formula families in through +// dynamic import, so it runs here under node's native type stripping with no +// bundler to rewrite anything. A specifier the stripper can't follow, or a +// chunk that only a bundler could resolve, fails here and nowhere else. +// +// The expressions below are the ones the host's BXL card fixtures compute +// (packages/host/tests/helpers/cards/bxl-tracking.ts), evaluated against the +// plain-object equivalent of those cards' field values and asserted to the +// same answers — so a divergence between the two environments shows up as a +// number that stops matching. The exhaustive function matrix lives in the +// bxl package's own suite; card-level behavior lives in the host suites. +module(basename(import.meta.filename), function () { + test('the package loads under node and reports its build identity', function (assert) { + assert.strictEqual( + typeof BXL_BUILD_INFO.version, + 'string', + 'a version string, so a consumer can tell which build answered', + ); + assert.true( + BXL_BUILD_INFO.features.includes('null-tolerance'), + 'the feature list is populated, not an empty placeholder', + ); + }); + + test('evaluates readable BXL over a plain object', function (assert) { + // Bare PascalCase identifiers resolve to camelCase field keys, the same + // fallback the card factory relies on when no schema is supplied. + assert.strictEqual( + evaluateBxl('ROUND((PaidAmount + ReserveAmount) * 100) / 100', { + paidAmount: 3200.5, + reserveAmount: 1500, + }).value, + 4700.5, + 'Claim.incurredAmount', + ); + assert.strictEqual( + evaluateBxl( + 'IFS(IncurredAmount < 1000, "Minor", IncurredAmount < 10000, "Standard", TRUE, "Large")', + { incurredAmount: 4700.5 }, + ).value, + 'Standard', + 'Claim.severityBand', + ); + // Excel blank semantics: absent numerics read as 0 rather than + // propagating null or throwing. + assert.strictEqual( + evaluateBxl('ROUND((PaidAmount + ReserveAmount) * 100) / 100', {}).value, + 0, + 'a blank input still computes', + ); + }); + + test('compiles readable BXL to canonical jq', function (assert) { + let compiled = compileBxl( + 'ROUND(ABS(PMT(FinancingApr / 12, 12, -AnnualPremium)) * 100) / 100', + ); + assert.strictEqual( + compiled.source, + 'ROUND(ABS(PMT(.financingApr / 12; 12; -.annualPremium)) * 100) / 100', + 'PascalCase labels become field paths and commas become jq semicolons', + ); + assert.true(compiled.changed, 'the compiler reports it rewrote the source'); + assert.deepEqual(compiled.warnings, [], 'no diagnostics on valid source'); + }); + + test('evaluates canonical jq handed straight to the engine', function (assert) { + // readableSyntax: false is what the `jq` tagged template selects — the + // source skips the readable-syntax compiler and reaches the jq parser + // unchanged. + assert.strictEqual( + evaluateBxl( + '[.claims[] | .paidAmount] | add // 0', + { claims: [{ paidAmount: 3200.5 }, { paidAmount: 780.25 }] }, + { readableSyntax: false }, + ).value, + 3980.75, + 'Policy.paidClaimsTotal', + ); + assert.strictEqual( + evaluateBxl( + '[.claims[] | .paidAmount] | add // 0', + { claims: [] }, + { + readableSyntax: false, + }, + ).value, + 0, + 'an empty aggregation falls back rather than yielding null', + ); + }); + + test('a lazy formula chunk resolves through dynamic import', async function (assert) { + // PMT lives in the financial family, which ships as its own chunk behind + // a dynamic `import('…/formula-financial.ts')`. The async entry point + // inspects the program, pulls in the families it names, and registers + // them before evaluating. + let run = await runNativeJqAsync( + 'ROUND(ABS(PMT(FinancingApr / 12, 12, -AnnualPremium)) * 100) / 100', + { financingApr: 0.06, annualPremium: 12000 }, + ); + assert.deepEqual(run.outputs, [1032.8], 'Policy.monthlyPayment'); + + // Auto-loading only widens the library set for that one async call — + // the default set a synchronous caller gets still holds just the eager + // core. Folding the chunks into that default set is a separate, + // explicit step, and it's what lets a host serve a `computeVia` that + // cannot await an import mid-compute. + await loadAllFormulaExtensions(); + assert.strictEqual( + evaluateBxl( + 'ROUND(ABS(PMT(FinancingApr / 12, 12, -AnnualPremium)) * 100) / 100', + { financingApr: 0.06, annualPremium: 12000 }, + ).value, + 1032.8, + 'the folded-in family is visible to synchronous evaluation', + ); + }); +}); diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index 764eb557fa1..b3c54b43e37 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -280,6 +280,7 @@ const ALL_TEST_FILES: string[] = [ './clamp-serialized-error-test', './sanitize-for-jsonb-test', './is-json-content-type-test', + './bxl-node-smoke-test', './file-size-limit-test', './content-hash-test', './fitted-formats-parity-test', diff --git a/packages/realm-server/tsconfig.json b/packages/realm-server/tsconfig.json index 30daa9a1ce3..898d3c00e77 100644 --- a/packages/realm-server/tsconfig.json +++ b/packages/realm-server/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "es2020", + "lib": ["ES2022", "DOM", "DOM.Iterable"], "allowJs": true, "module": "nodenext", "moduleResolution": "nodenext", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc3126ea939..b8c5478ba17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2707,6 +2707,9 @@ importers: '@cardstack/boxel-ui': specifier: workspace:* version: link:../boxel-ui + '@cardstack/bxl': + specifier: workspace:* + version: link:../bxl '@cardstack/eslint-plugin-boxel': specifier: workspace:* version: link:../eslint-plugin-boxel From 703ed32447926a4051b8008a97e1b8e283cb78cd Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 18 Aug 2026 11:20:39 -0400 Subject: [PATCH 2/3] Trigger the host suite on BXL changes too Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci-host.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci-host.yaml b/.github/workflows/ci-host.yaml index 452d3a5ca21..60ec0ebc37c 100644 --- a/.github/workflows/ci-host.yaml +++ b/.github/workflows/ci-host.yaml @@ -10,6 +10,7 @@ on: - "packages/base/**" - "packages/boxel-icons/**" - "packages/boxel-ui/**" + - "packages/bxl/**" - "packages/eslint-plugin-boxel/**" - "packages/realm-server/**" - "packages/runtime-common/**" From 3e68b243ab5e1670843995aa2f22fc5a91ce1603 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 18 Aug 2026 11:57:11 -0400 Subject: [PATCH 3/3] Say what the null-tolerance tests actually exercise A blank or zero divisor yields null inside the jq engine's own null-tolerant arithmetic; only a function that raises an Excel sentinel throws, and only that path reaches the factory's catch. Assert both, and describe each by the mechanism that produces it. Name the invariant behind the staleness assertions, pair the superseded-value check with its positive counterpart, and take the revisit nonce off the caller. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/integration/bxl-indexing-test.gts | 142 ++++++++++++------ .../realm-server/tests/bxl-node-smoke-test.ts | 43 +++--- 2 files changed, 125 insertions(+), 60 deletions(-) diff --git a/packages/host/tests/integration/bxl-indexing-test.gts b/packages/host/tests/integration/bxl-indexing-test.gts index 8b52904de5b..e31473bf3b8 100644 --- a/packages/host/tests/integration/bxl-indexing-test.gts +++ b/packages/host/tests/integration/bxl-indexing-test.gts @@ -22,19 +22,24 @@ import { setupMockMatrix } from '../helpers/mock-matrix'; import { searchCardsForTest } from '../helpers/search-cards'; import { setupRenderingTest } from '../helpers/setup'; -// BXL's `derive` profile runs at index time, so the search doc is where its -// correctness is load-bearing: it is what queries filter and sort on, and it -// is regenerated from scratch on every pass that touches the card. This -// suite covers that indexing dimension — BXL computeds land in the search -// doc in a shape the query engine can match on, and they recompute whenever -// an edit invalidates the card, whether the edit lands on the card itself, -// on a card it reaches through a link, or on the module that declares the +// A BXL `computeVia` runs whenever the field is read, and indexing is the +// read that persists: the search doc is what queries filter and sort on, +// and it is regenerated on every pass that touches the card. This suite +// covers that indexing dimension — BXL computeds land in the search doc in +// a shape the query engine can match on, and they recompute whenever an +// edit invalidates the card, whether the edit lands on the card itself, on +// a card it reaches through a link, or on the module that declares the // formula. // -// Per-function values are pinned by the expression suite and the cycle -// contract by the cyclic-graph suite; both read the same tracking-realm -// fixture, so an assertion here is about when a value is recomputed rather -// than what the formula returns. +// Recomputation is the part with real teeth. `expression()` memoizes each +// compute per card instance, so a memo that outlived its cycle would show +// up as a computed that quietly keeps a superseded value across an +// incremental pass — indistinguishable, from the outside, from an +// invalidation that never fired. +// +// The value each formula returns is pinned by the expression suite and the +// cycle contract by the cyclic-graph suite; all three read the same +// tracking-realm fixture. module('Integration | bxl indexing', function (hooks) { setupRenderingTest(hooks); setupBaseRealm(hooks); @@ -104,12 +109,15 @@ module('Integration | bxl indexing', function (hooks) { ); } - // A write of byte-identical content is skipped, so each revisit needs a - // distinguishable policy status. - async function revisitPolicy(policyStatus: string) { + // Query-backed aggregations converge when the policy is next visited, and + // a write of byte-identical content is skipped — so each revisit has to + // carry a status no earlier one used. No formula reads policyStatus; the + // counter exists only to make the write land. + let revisitCount = 0; + async function revisitPolicy() { await realm.write( 'Policy/pol-100.json', - JSON.stringify(bxlTrackingPol100Doc(policyStatus)), + JSON.stringify(bxlTrackingPol100Doc(`Reviewed ${++revisitCount}`)), ); } @@ -133,7 +141,6 @@ module('Integration | bxl indexing', function (hooks) { assert.deepEqual( await matchingIds({ filter: { on: claimRef, range: { incurredAmount: { gt: 1000 } } }, - sort: [{ on: claimRef, by: 'claimId', direction: 'asc' }], }), [`${testRealmURL}Claim/clm-1`], 'a number computed matches by range, so it indexed as a number', @@ -210,14 +217,15 @@ module('Integration | bxl indexing', function (hooks) { }); let searchDoc = await indexedSearchDoc(`${testRealmURL}Claim/clm-1`); - assert.strictEqual(searchDoc.incurredAmount, 21500); + assert.strictEqual(searchDoc.incurredAmount, 21500, 'the sum recomputes'); assert.strictEqual( searchDoc.severityBand, 'Large', 'the band follows the new amount across its threshold', ); - // The stale value is gone from the index, not merely shadowed by a - // fresh one — the previous band no longer matches anything. + // The index moved the claim from one band to the other, rather than + // dropping it or keeping both: a memo surviving the pass would leave it + // matching Standard, and a lost row would match neither. assert.deepEqual( await matchingIds({ filter: { on: claimRef, eq: { severityBand: 'Standard' } }, @@ -225,6 +233,13 @@ module('Integration | bxl indexing', function (hooks) { [], 'the superseded value is out of the search doc', ); + assert.deepEqual( + await matchingIds({ + filter: { on: claimRef, eq: { severityBand: 'Large' } }, + }), + [`${testRealmURL}Claim/clm-1`], + 'and the fresh value is in it', + ); }); test('editing a linked card recomputes the cards that traverse to it', async function (assert) { @@ -239,6 +254,12 @@ module('Integration | bxl indexing', function (hooks) { }), ); + let customerDoc = await indexedSearchDoc(`${testRealmURL}Customer/acme`); + assert.strictEqual( + customerDoc.displayLabel, + 'Acme Logistics (Platinum)', + "the edited card's own computed recomputes", + ); let policyDoc = await indexedSearchDoc(`${testRealmURL}Policy/pol-100`); assert.strictEqual( policyDoc.customerName, @@ -263,18 +284,33 @@ module('Integration | bxl indexing', function (hooks) { // The only stored edge runs claim → policy; the policy's `claims` side // is a query resolved against the live index when the policy is - // visited. Writing the claim invalidates the claim, so the policy still - // carries the aggregate it computed on its last visit. + // visited. A dependency read through a query context is not recorded as + // an invalidation edge, so writing the claim reindexes the claim alone + // and the policy keeps the aggregate from its last visit. These two + // assert that staleness deliberately — they are the contract as it + // stands, not the behavior anyone would want. let searchDoc = await indexedSearchDoc(`${testRealmURL}Policy/pol-100`); - assert.strictEqual(searchDoc.paidClaimsTotal, 3980.75); - assert.strictEqual(searchDoc.openClaimCount, 1); + assert.strictEqual( + searchDoc.paidClaimsTotal, + 3980.75, + 'the claim edit does not reach the policy through the query inverse', + ); + assert.strictEqual( + searchDoc.openClaimCount, + 1, + 'nor does the status change it would have counted', + ); // That next visit recomputes against the now-current claims. - await revisitPolicy('Reviewed'); + await revisitPolicy(); searchDoc = await indexedSearchDoc(`${testRealmURL}Policy/pol-100`); - assert.strictEqual(searchDoc.paidClaimsTotal, 4200.5); - assert.strictEqual(searchDoc.reservedClaimsTotal, 2000); + assert.strictEqual( + searchDoc.paidClaimsTotal, + 4200.5, + 'the aggregate picks up the edited claim', + ); + assert.strictEqual(searchDoc.reservedClaimsTotal, 2000, 'and its reserve'); assert.strictEqual( searchDoc.openClaimCount, 2, @@ -294,27 +330,35 @@ module('Integration | bxl indexing', function (hooks) { paidAmount: 19.25, reserveAmount: 0, }); - await revisitPolicy('Reviewed'); + await revisitPolicy(); - let searchDoc = await indexedSearchDoc(`${testRealmURL}Policy/pol-100`); - assert.strictEqual(searchDoc.paidClaimsTotal, 4000); - assert.strictEqual(searchDoc.openClaimCount, 2); + let policyId = `${testRealmURL}Policy/pol-100`; + let searchDoc = await indexedSearchDoc(policyId); + assert.strictEqual(searchDoc.paidClaimsTotal, 4000, 'the total grows'); + assert.strictEqual(searchDoc.openClaimCount, 2, 'and so does the tally'); assert.deepEqual( searchDoc.claimPolicyIds, - Array(3).fill(`${testRealmURL}Policy/pol-100`), + [policyId, policyId, policyId], 'the new claim joins the inverse the cycle-walking formula reads', ); await realm.delete('Claim/clm-4.json'); - await revisitPolicy('Audited'); + await revisitPolicy(); - searchDoc = await indexedSearchDoc(`${testRealmURL}Policy/pol-100`); - assert.strictEqual(searchDoc.paidClaimsTotal, 3980.75); - assert.strictEqual(searchDoc.openClaimCount, 1); + assert.strictEqual( + await realm.realmIndexQueryEngine.instance( + new URL(`${testRealmURL}Claim/clm-4`), + ), + undefined, + 'the deleted claim leaves the index entirely', + ); + searchDoc = await indexedSearchDoc(policyId); + assert.strictEqual(searchDoc.paidClaimsTotal, 3980.75, 'the total shrinks'); + assert.strictEqual(searchDoc.openClaimCount, 1, 'and so does the tally'); assert.deepEqual( searchDoc.claimPolicyIds, - Array(2).fill(`${testRealmURL}Policy/pol-100`), - 'and leaves it again when the claim is deleted', + [policyId, policyId], + 'and the claim leaves the inverse when it is deleted', ); }); @@ -333,6 +377,7 @@ module('Integration | bxl indexing', function (hooks) { assert.strictEqual( (await indexedSearchDoc(`${testRealmURL}Policy/pol-100`)).premiumWithTax, 13200, + 'the new multiplier reaches the instance in hand', ); assert.strictEqual( (await indexedSearchDoc(`${testRealmURL}Policy/pol-200`)).premiumWithTax, @@ -343,10 +388,12 @@ module('Integration | bxl indexing', function (hooks) { test("an edit that strips a card's inputs still yields a clean index entry", async function (assert) { // Every computed on the policy now reads a missing link or a blank - // number, and one of them divides by a literal zero. An Excel error - // escaping as a thrown exception would turn the whole card into an - // instance-error entry and strand the computeds that are still - // perfectly well defined. + // number. Two distinct tolerances keep the card indexable, and a gap in + // either one turns it into an instance-error entry that strands the + // computeds still perfectly well defined: arithmetic on a null or a + // zero divisor yields null inside the engine, while a function that + // raises an Excel sentinel — NA() — throws, and the factory catches + // that at the boundary. await realm.write( 'Policy/pol-100.json', JSON.stringify({ @@ -374,7 +421,17 @@ module('Integration | bxl indexing', function (hooks) { 'the dropped link recomputes to null rather than keeping a stale name', ); let divByZero = searchDoc.divByZero ?? null; - assert.strictEqual(divByZero, null, 'the #DIV/0! sentinel clips to null'); + assert.strictEqual( + divByZero, + null, + 'dividing a blank premium by zero yields null inside the engine', + ); + let notApplicable = searchDoc.notApplicable ?? null; + assert.strictEqual( + notApplicable, + null, + 'and the sentinel NA() throws is caught at the factory boundary', + ); assert.strictEqual( searchDoc.premiumWithTax, 0, @@ -388,6 +445,7 @@ module('Integration | bxl indexing', function (hooks) { filter: { on: policyRef, range: { 'riskBand.score': { gt: 1 } } }, }), [], + 'the pre-edit risk score is out of the index', ); }); }); diff --git a/packages/realm-server/tests/bxl-node-smoke-test.ts b/packages/realm-server/tests/bxl-node-smoke-test.ts index c90b784dfff..31329ad28f4 100644 --- a/packages/realm-server/tests/bxl-node-smoke-test.ts +++ b/packages/realm-server/tests/bxl-node-smoke-test.ts @@ -10,23 +10,26 @@ import { } from '@cardstack/bxl'; // BXL is an isomorphic package: the host bundles it through Vite and serves -// it to card code, while platform code reaches it from node. Cards can't run -// in node, so node-side coverage is deliberately a smoke suite over plain -// JS objects — enough to prove the runtime is the same one the browser gets, -// not a second implementation. +// it to card code, and it is meant to be importable from node so platform +// code can reach it directly. Nothing on the server does yet, which is +// exactly why this suite is here — it keeps that direction working before +// the first consumer depends on it. // -// What only node can break is the delivery: the package ships raw `.ts` with -// `.ts` import specifiers and pulls its heavy formula families in through -// dynamic import, so it runs here under node's native type stripping with no -// bundler to rewrite anything. A specifier the stripper can't follow, or a -// chunk that only a bundler could resolve, fails here and nowhere else. +// Two things can only break from outside the package. The bare specifier +// `@cardstack/bxl` has to resolve through the package's `exports` map from +// a sibling workspace package and land on raw `.ts` that node's native type +// stripping accepts, with no bundler to rewrite anything. And a formula +// family the caller never named has to arrive on its own through the async +// entry point's dynamic import. The package's own suite covers the library +// from the inside and holds the exhaustive function matrix; card-level +// behavior lives in the host suites. Cards can't run in node at all, so +// this stays a smoke suite over plain JS objects. // -// The expressions below are the ones the host's BXL card fixtures compute -// (packages/host/tests/helpers/cards/bxl-tracking.ts), evaluated against the -// plain-object equivalent of those cards' field values and asserted to the -// same answers — so a divergence between the two environments shows up as a -// number that stops matching. The exhaustive function matrix lives in the -// bxl package's own suite; card-level behavior lives in the host suites. +// The expressions are lifted from the host's BXL card fixtures +// (packages/host/tests/helpers/cards/bxl-tracking.ts) and asserted to the +// same answers, so the two environments can be compared by reading them +// side by side. The values are copies, not imports — nothing enforces that +// they stay in step. module(basename(import.meta.filename), function () { test('the package loads under node and reports its build identity', function (assert) { assert.strictEqual( @@ -118,11 +121,15 @@ module(basename(import.meta.filename), function () { ); assert.deepEqual(run.outputs, [1032.8], 'Policy.monthlyPayment'); - // Auto-loading only widens the library set for that one async call — - // the default set a synchronous caller gets still holds just the eager + // Auto-loading widens the library set for that one async call only; the + // default set a synchronous caller gets still holds just the eager // core. Folding the chunks into that default set is a separate, // explicit step, and it's what lets a host serve a `computeVia` that - // cannot await an import mid-compute. + // cannot await an import mid-compute. That the async path leaves the + // default set alone can only be observed in a fresh process, so it is + // pinned by the bxl package's own smoke suite rather than here — the + // fold below is global and irreversible, and this file shares a node + // process with every other realm-server test. await loadAllFormulaExtensions(); assert.strictEqual( evaluateBxl(