|
| 1 | +import type { RenderingTestContext } from '@ember/test-helpers'; |
| 2 | +import { settled } from '@ember/test-helpers'; |
| 3 | + |
| 4 | +import { getService } from '@universal-ember/test-support'; |
| 5 | +import { module, test } from 'qunit'; |
| 6 | + |
| 7 | +import { |
| 8 | + baseRealm, |
| 9 | + rri, |
| 10 | + setSearchTimingSinkForTests, |
| 11 | + X_BOXEL_LOGGING_CORRELATION_ID_HEADER, |
| 12 | +} from '@cardstack/runtime-common'; |
| 13 | +import type { Loader } from '@cardstack/runtime-common/loader'; |
| 14 | +import type { Query } from '@cardstack/runtime-common/query'; |
| 15 | + |
| 16 | +import type NetworkService from '@cardstack/host/services/network'; |
| 17 | +import type StoreService from '@cardstack/host/services/store'; |
| 18 | + |
| 19 | +import { |
| 20 | + testRealmURL, |
| 21 | + setupCardLogs, |
| 22 | + setupLocalIndexing, |
| 23 | + setupIntegrationTestRealm, |
| 24 | +} from '../helpers'; |
| 25 | +import { setupMockMatrix } from '../helpers/mock-matrix'; |
| 26 | +import { setupRenderingTest } from '../helpers/setup'; |
| 27 | + |
| 28 | +// End-to-end coverage for the search correlation id: the in-realm browser |
| 29 | +// (the prerendered host SPA) mints `x-boxel-logging-correlation-id` on its |
| 30 | +// `_federated-search` fetch, and the realm-server's search path emits a |
| 31 | +// `realm:search-timing` line keyed by that same id. This proves the id |
| 32 | +// threads all the way from the client that originated it through to the |
| 33 | +// server log a triage would join against. |
| 34 | +// |
| 35 | +// The host test exercises the *real* code on both ends: the SPA's |
| 36 | +// `loggingCorrelationIdHeader()` stamps the header, and the realm-server-mock hands it |
| 37 | +// to the real `searchRealms`, which emits the line. Only the prerender |
| 38 | +// context flag is simulated (the host normally sets it inside a prerender |
| 39 | +// tab). |
| 40 | + |
| 41 | +const personModule = ` |
| 42 | + import { contains, field, CardDef } from 'https://cardstack.com/base/card-api'; |
| 43 | + import StringField from 'https://cardstack.com/base/string'; |
| 44 | +
|
| 45 | + export class Person extends CardDef { |
| 46 | + static displayName = 'Person'; |
| 47 | + @field name = contains(StringField); |
| 48 | + } |
| 49 | +`; |
| 50 | + |
| 51 | +let loader: Loader; |
| 52 | + |
| 53 | +module('Integration | search correlation id', function (hooks) { |
| 54 | + setupRenderingTest(hooks); |
| 55 | + setupLocalIndexing(hooks); |
| 56 | + let mockMatrixUtils = setupMockMatrix(hooks); |
| 57 | + |
| 58 | + hooks.beforeEach(function (this: RenderingTestContext) { |
| 59 | + loader = getService('loader-service').loader; |
| 60 | + }); |
| 61 | + |
| 62 | + setupCardLogs( |
| 63 | + hooks, |
| 64 | + async () => await loader.import(`${baseRealm.url}card-api`), |
| 65 | + ); |
| 66 | + |
| 67 | + hooks.beforeEach(async function () { |
| 68 | + await setupIntegrationTestRealm({ |
| 69 | + mockMatrixUtils, |
| 70 | + contents: { |
| 71 | + 'person.gts': personModule, |
| 72 | + 'person-1.json': { |
| 73 | + data: { |
| 74 | + attributes: { name: 'Alice' }, |
| 75 | + meta: { adoptsFrom: { module: './person', name: 'Person' } }, |
| 76 | + }, |
| 77 | + }, |
| 78 | + 'person-2.json': { |
| 79 | + data: { |
| 80 | + attributes: { name: 'Bob' }, |
| 81 | + meta: { adoptsFrom: { module: './person', name: 'Person' } }, |
| 82 | + }, |
| 83 | + }, |
| 84 | + }, |
| 85 | + }); |
| 86 | + }); |
| 87 | + |
| 88 | + // Restore globals + sink between tests so a failure can't leak into the |
| 89 | + // next test or the rest of the suite. |
| 90 | + hooks.afterEach(function () { |
| 91 | + delete (globalThis as Record<string, unknown>).__boxelRenderContext; |
| 92 | + setSearchTimingSinkForTests(undefined); |
| 93 | + }); |
| 94 | + |
| 95 | + const personQuery: Query = { |
| 96 | + filter: { type: { module: rri(`${testRealmURL}person`), name: 'Person' } }, |
| 97 | + }; |
| 98 | + |
| 99 | + test('a client-issued search threads its correlation id into the server timing log', async function (assert) { |
| 100 | + let store = getService('store') as StoreService; |
| 101 | + let network = getService('network') as NetworkService; |
| 102 | + |
| 103 | + // Capture the realm-server's `realm:search-timing` emissions. |
| 104 | + let timingLines: string[] = []; |
| 105 | + setSearchTimingSinkForTests((line) => timingLines.push(line)); |
| 106 | + |
| 107 | + // Capture the correlation id the client actually puts on the wire. |
| 108 | + let sentRequestIds: string[] = []; |
| 109 | + let spy = async (request: Request) => { |
| 110 | + if (new URL(request.url).pathname.endsWith('/_federated-search')) { |
| 111 | + let id = request.headers.get(X_BOXEL_LOGGING_CORRELATION_ID_HEADER); |
| 112 | + if (id) { |
| 113 | + sentRequestIds.push(id); |
| 114 | + } |
| 115 | + } |
| 116 | + // Return null to fall through to the realm-server-mock route. |
| 117 | + return null; |
| 118 | + }; |
| 119 | + network.virtualNetwork.mount(spy, { prepend: true }); |
| 120 | + |
| 121 | + // Simulate the prerender context, which is what gates the host's |
| 122 | + // correlation-id stamping (mirrors a card rendering inside a prerender |
| 123 | + // tab issuing a query-backed search). |
| 124 | + (globalThis as Record<string, unknown>).__boxelRenderContext = true; |
| 125 | + |
| 126 | + let results = await store.search(personQuery, [testRealmURL]); |
| 127 | + await settled(); |
| 128 | + |
| 129 | + assert.strictEqual(results.length, 2, 'the search returned both people'); |
| 130 | + |
| 131 | + assert.strictEqual( |
| 132 | + sentRequestIds.length, |
| 133 | + 1, |
| 134 | + 'the client stamped exactly one correlation id on its _federated-search fetch', |
| 135 | + ); |
| 136 | + let sentId = sentRequestIds[0]; |
| 137 | + assert.ok( |
| 138 | + /^[A-Za-z0-9._:-]{8,}$/.test(sentId), |
| 139 | + `client-minted correlation id looks well-formed (${sentId})`, |
| 140 | + ); |
| 141 | + |
| 142 | + let matching = timingLines.filter((line) => |
| 143 | + line.includes(`corr=${sentId}`), |
| 144 | + ); |
| 145 | + assert.strictEqual( |
| 146 | + matching.length, |
| 147 | + 1, |
| 148 | + `the server emitted exactly one realm:search-timing line keyed by the client's id (lines: ${JSON.stringify( |
| 149 | + timingLines, |
| 150 | + )})`, |
| 151 | + ); |
| 152 | + assert.ok( |
| 153 | + /\bsql=\d+\b/.test(matching[0]), |
| 154 | + `the timing line carries the sql stage (${matching[0]})`, |
| 155 | + ); |
| 156 | + assert.ok( |
| 157 | + /\bloadLinks=\d+\b/.test(matching[0]), |
| 158 | + `the timing line carries the loadLinks stage (${matching[0]})`, |
| 159 | + ); |
| 160 | + }); |
| 161 | + |
| 162 | + test('a non-prerender search stamps no id and emits no timing line', async function (assert) { |
| 163 | + let store = getService('store') as StoreService; |
| 164 | + let network = getService('network') as NetworkService; |
| 165 | + |
| 166 | + let timingLines: string[] = []; |
| 167 | + setSearchTimingSinkForTests((line) => timingLines.push(line)); |
| 168 | + |
| 169 | + let sawHeader = false; |
| 170 | + let spy = async (request: Request) => { |
| 171 | + if ( |
| 172 | + new URL(request.url).pathname.endsWith('/_federated-search') && |
| 173 | + request.headers.get(X_BOXEL_LOGGING_CORRELATION_ID_HEADER) |
| 174 | + ) { |
| 175 | + sawHeader = true; |
| 176 | + } |
| 177 | + return null; |
| 178 | + }; |
| 179 | + network.virtualNetwork.mount(spy, { prepend: true }); |
| 180 | + |
| 181 | + // No __boxelRenderContext: live SPA traffic must not stamp the header |
| 182 | + // (so it pays nothing and the server emits no timing line). |
| 183 | + let results = await store.search(personQuery, [testRealmURL]); |
| 184 | + await settled(); |
| 185 | + |
| 186 | + assert.strictEqual(results.length, 2, 'the search still returns results'); |
| 187 | + assert.false( |
| 188 | + sawHeader, |
| 189 | + 'live (non-prerender) traffic sends no x-boxel-logging-correlation-id header', |
| 190 | + ); |
| 191 | + assert.strictEqual( |
| 192 | + timingLines.length, |
| 193 | + 0, |
| 194 | + 'no realm:search-timing line is emitted without a correlation id', |
| 195 | + ); |
| 196 | + }); |
| 197 | +}); |
0 commit comments