diff --git a/src/code/providers/interactive-api-provider.test.ts b/src/code/providers/interactive-api-provider.test.ts index d4d61e66..c14cc486 100644 --- a/src/code/providers/interactive-api-provider.test.ts +++ b/src/code/providers/interactive-api-provider.test.ts @@ -730,6 +730,13 @@ describe('InteractiveApiProvider', () => { expect(shouldSaveAsAttachment(contentAtThreshold)).toBe(true) expect(shouldSaveAsAttachment(contentAboveThreshold)).toBe(true) + // CFM-18 / spec R6: the size check measures UTF-8 bytes, not UTF-16 code units. + // A run of multi-byte characters can stay below the threshold by String length + // yet exceed it - and Firestore's byte-based 1 MiB limit - once UTF-8 encoded. + const multiByteContent = "€".repeat(kDynamicAttachmentSizeThreshold / 2) // "€" is 1 code unit, 3 UTF-8 bytes + expect(JSON.stringify(multiByteContent).length).toBeLessThan(kDynamicAttachmentSizeThreshold) + expect(shouldSaveAsAttachment(multiByteContent)).toBe(true) + // with an explicit kAttachmentUrlParameter value attachments are always used setQueryParams(`interactiveApi=${kAttachmentUrlParameter}`) expect(shouldSaveAsAttachment(contentBelowThreshold)).toBe(true) diff --git a/src/code/providers/interactive-api-provider.ts b/src/code/providers/interactive-api-provider.ts index 57402b81..5322b56f 100644 --- a/src/code/providers/interactive-api-provider.ts +++ b/src/code/providers/interactive-api-provider.ts @@ -20,7 +20,11 @@ export const shouldSaveAsAttachment = (content: any) => { return true } - const aboveDynamicThreshold = JSON.stringify(content).length >= kDynamicAttachmentSizeThreshold + // Measure the UTF-8 byte length, not String.prototype.length: the latter counts + // UTF-16 code units and undercounts non-ASCII content, whereas Firestore's 1 MiB + // document limit — and kDynamicAttachmentSizeThreshold — are byte-based. (spec R6) + const serializedBytes = new TextEncoder().encode(JSON.stringify(content)).byteLength + const aboveDynamicThreshold = serializedBytes >= kDynamicAttachmentSizeThreshold if (aboveDynamicThreshold) { return true } @@ -61,8 +65,19 @@ interface InteractiveApiProviderParams { // pass `interactiveApi=attachment` as url parameter to always save state as an attachment export const kAttachmentUrlParameter = "attachment" -// can save it twice with room to spare in 1MB Firestore limit -export const kDynamicAttachmentSizeThreshold = 480 * 1024 +// Interactive state at or above this size is offloaded to an S3 attachment; +// smaller state is saved directly into the Firestore answer document. +// +// CFM-18: this was 480 KiB, justified as "can save it twice with room to spare +// in the 1MB Firestore limit" (480 * 2 = 960 KiB). That math was wrong. Activity +// Player stores the interactive state twice in one answer doc (`answer` + +// `report_state`), but it embeds each copy as an escaped JSON *string* inside a +// `report` wrapper alongside `attachments` and other fields. Measured expansion +// is ~2.2 bytes of Firestore document per char of interactive state, so a state +// near 480 KiB produced a ~1.06 MB document that Firestore rejected. +// The 1 MiB (1,048,576 byte) limit / 2.2 ≈ 465 KiB is the hard ceiling; +// 400 KiB leaves margin for escaping-density variation and wrapper overhead. +export const kDynamicAttachmentSizeThreshold = 400 * 1024 // in solidarity with legacy DocumentStore implementation and S3 sharing implementation export const kLegacyAttachmentFilename = "file.json" diff --git a/src/test/setupTests.ts b/src/test/setupTests.ts index 298e39a1..a6e32d76 100644 --- a/src/test/setupTests.ts +++ b/src/test/setupTests.ts @@ -16,6 +16,10 @@ g.createReactClassFactory = (classDef: any) => g.createReactFactory(g.createReac // providers use window.alert() (which isn't implemented in JSDom) to signal unimplemented methods window.alert = jest.fn(msg => console.error(msg)) +// TextEncoder is a standard browser global but isn't exposed by the jsdom test +// environment; polyfill it from Node's util module so code under test can use it. +g.TextEncoder = g.TextEncoder ?? require('util').TextEncoder + declare global { function jestSpyConsole(method: ConsoleMethod, fn: JestSpyConsoleFn, options?: IJestSpyConsoleOptions): Promise