From 717048f45fc0641f4f6f9a208b45257a288f4fb2 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 25 Jun 2026 08:32:09 -0700 Subject: [PATCH 1/6] [FSSDK-12813] Normalize decision event campaign_id, variation_id, and entity_id --- .../event_builder/log_event.spec.ts | 420 +++++++++++++++++- .../event_builder/log_event.ts | 57 ++- lib/optimizely/index.tests.js | 3 +- 3 files changed, 457 insertions(+), 23 deletions(-) diff --git a/lib/event_processor/event_builder/log_event.spec.ts b/lib/event_processor/event_builder/log_event.spec.ts index ad3b22b94..a0176049f 100644 --- a/lib/event_processor/event_builder/log_event.spec.ts +++ b/lib/event_processor/event_builder/log_event.spec.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { makeEventBatch, @@ -26,6 +26,9 @@ import { Region } from '../../project_config/project_config'; describe('makeEventBatch', () => { it('should build a batch with single impression event when experiment and variation are defined', () => { + // FSSDK-12813: campaign_id, experiment_id, variation_id, and entity_id + // are normalized to numeric-strings on the wire. Test fixtures use valid + // numeric IDs so the happy-path output matches expectations. const impressionEvent: ImpressionEvent = { type: 'impression', timestamp: 69, @@ -47,16 +50,16 @@ describe('makeEventBatch', () => { }, layer: { - id: 'layerId', + id: '11111', }, experiment: { - id: 'expId', + id: '22222', key: 'expKey', }, variation: { - id: 'varId', + id: '33333', key: 'varKey', }, @@ -82,9 +85,9 @@ describe('makeEventBatch', () => { { decisions: [ { - campaign_id: 'layerId', - experiment_id: 'expId', - variation_id: 'varId', + campaign_id: '11111', + experiment_id: '22222', + variation_id: '33333', metadata: { flag_key: 'flagKey1', rule_key: 'expKey', @@ -96,7 +99,7 @@ describe('makeEventBatch', () => { ], events: [ { - entity_id: 'layerId', + entity_id: '11111', timestamp: 69, key: 'campaign_activated', uuid: 'uuid', @@ -125,6 +128,10 @@ describe('makeEventBatch', () => { }) it('should build a batch with simlge impression event when experiment and variation are not defined', () => { + // FSSDK-12813: When campaign_id, experiment_id, and variation_id are all + // missing/invalid, the normalized wire output is campaign_id=null, + // variation_id=null, and entity_id MUST equal campaign_id byte-for-byte + // (FR-009). experiment_id is left as-is. const impressionEvent: ImpressionEvent = { type: 'impression', timestamp: 69, @@ -183,7 +190,8 @@ describe('makeEventBatch', () => { { campaign_id: null, experiment_id: "", - variation_id: "", + // FSSDK-12813: empty/null variation_id normalizes to null. + variation_id: null, metadata: { flag_key: 'flagKey1', rule_key: '', @@ -691,17 +699,19 @@ describe('makeEventBatch', () => { attributes: [{ entityId: 'attr1-id', key: 'attr1-key', value: 'attr1-value' }], }, + // FSSDK-12813: Use numeric-string IDs so happy-path output is unchanged + // after normalization. layer: { - id: 'layerId', + id: '11111', }, experiment: { - id: 'expId', + id: '22222', key: 'expKey', }, variation: { - id: 'varId', + id: '33333', key: 'varKey', }, @@ -728,9 +738,9 @@ describe('makeEventBatch', () => { { decisions: [ { - campaign_id: 'layerId', - experiment_id: 'expId', - variation_id: 'varId', + campaign_id: '11111', + experiment_id: '22222', + variation_id: '33333', metadata: { flag_key: 'flagKey1', rule_key: 'expKey', @@ -742,7 +752,7 @@ describe('makeEventBatch', () => { ], events: [ { - entity_id: 'layerId', + entity_id: '11111', timestamp: 69, key: 'campaign_activated', uuid: 'uuid', @@ -809,6 +819,8 @@ describe('makeEventBatch', () => { describe('buildLogEvent', () => { it('should select the correct URL based on the event context region', () => { + // FSSDK-12813: Use numeric-string IDs to avoid normalization side effects + // unrelated to the URL-region behavior being tested here. const baseEvent: ImpressionEvent = { type: 'impression', timestamp: 69, @@ -827,14 +839,14 @@ describe('buildLogEvent', () => { attributes: [] }, layer: { - id: 'layerId' + id: '11111' }, experiment: { - id: 'expId', + id: '22222', key: 'expKey' }, variation: { - id: 'varId', + id: '33333', key: 'varKey' }, ruleKey: 'expKey', @@ -868,3 +880,373 @@ describe('buildLogEvent', () => { expect(euResult.url).toBe('https://eu.logx.optimizely.com/v1/events'); }); }); + +/** + * FSSDK-12813: Decision-event ID normalization tests. + * + * These tests pin the normalization contract for decisions[].campaign_id, + * decisions[].variation_id, and events[].entity_id on impression events: + * + * - campaign_id MUST be a non-empty decimal-digit string; if not, fall back + * to experiment_id; if experiment_id is also invalid, emit null. + * - variation_id MUST be a non-empty decimal-digit string OR null; any + * invalid input normalizes to null. + * - events[].entity_id (impression only) follows the same rule as + * campaign_id and MUST equal decisions[].campaign_id byte-for-byte. + * - The rule applies uniformly across all decision types (experiment, + * feature-test, rollout, holdout) — no per-type branching. + * - Conversion events derive entity_id from event.id and are left + * unchanged. + * - Event dispatch is never dropped or failed by normalization, and + * normalization never logs. + */ +describe('decision event ID normalization (FSSDK-12813)', () => { + const baseContext = { + accountId: 'accountId', + projectId: 'projectId', + clientName: 'node-sdk', + clientVersion: '3.0.0', + revision: 'revision', + botFiltering: true, + anonymizeIP: true, + }; + + const baseUser = { + id: 'userId', + attributes: [], + }; + + const makeImpression = (overrides: { + layerId: string | null; + experimentId: string | null; + variationId: string | null; + ruleType: string; + }): ImpressionEvent => ({ + type: 'impression', + timestamp: 69, + uuid: 'uuid', + context: { ...baseContext }, + user: { ...baseUser, attributes: [] }, + layer: { id: overrides.layerId }, + experiment: { id: overrides.experimentId, key: 'expKey' }, + variation: { id: overrides.variationId, key: 'varKey' }, + ruleKey: 'expKey', + flagKey: 'flagKey1', + ruleType: overrides.ruleType, + enabled: true, + }); + + const getDecision = (event: ImpressionEvent) => + makeEventBatch([event]).visitors[0].snapshots[0].decisions![0]; + + const getSnapshotEvent = (event: ImpressionEvent) => + makeEventBatch([event]).visitors[0].snapshots[0].events[0]; + + // --------------------------------------------------------------------------- + // FR-001 / FR-002: campaign_id normalization + // --------------------------------------------------------------------------- + describe('campaign_id (FR-001 / FR-002)', () => { + it('preserves a valid numeric-string layerId', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('12345'); + }); + + it('preserves a numeric-string layerId with leading zeros', () => { + const decision = getDecision( + makeImpression({ layerId: '00042', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('00042'); + }); + + it('substitutes experiment_id when layerId is null', () => { + const decision = getDecision( + makeImpression({ layerId: null, experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('67890'); + }); + + it('substitutes experiment_id when layerId is an empty string', () => { + const decision = getDecision( + makeImpression({ layerId: '', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('67890'); + }); + + it('substitutes experiment_id when layerId is whitespace', () => { + const decision = getDecision( + makeImpression({ layerId: ' ', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('67890'); + }); + + it('substitutes experiment_id when layerId is a non-numeric string', () => { + const decision = getDecision( + makeImpression({ layerId: 'layer_abc', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('67890'); + }); + + it('substitutes experiment_id when layerId contains negative sign', () => { + const decision = getDecision( + makeImpression({ layerId: '-123', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('67890'); + }); + + it('substitutes experiment_id when layerId is a decimal', () => { + const decision = getDecision( + makeImpression({ layerId: '123.45', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('67890'); + }); + + it('substitutes experiment_id when layerId is in exponential notation', () => { + const decision = getDecision( + makeImpression({ layerId: '1e5', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('67890'); + }); + + it('emits null when both layerId and experiment_id are invalid', () => { + const decision = getDecision( + makeImpression({ layerId: null, experimentId: null, variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBeNull(); + }); + + it('emits null when both layerId and experiment_id are non-numeric strings', () => { + const decision = getDecision( + makeImpression({ layerId: 'abc', experimentId: 'exp_42', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBeNull(); + }); + + it('emits null when both layerId and experiment_id are empty strings', () => { + const decision = getDecision( + makeImpression({ layerId: '', experimentId: '', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBeNull(); + }); + }); + + // --------------------------------------------------------------------------- + // FR-003 / FR-004: variation_id normalization + // --------------------------------------------------------------------------- + describe('variation_id (FR-003 / FR-004)', () => { + it('preserves a valid numeric-string variationId', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: '99999', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBe('99999'); + }); + + it('preserves a numeric-string variationId with leading zeros', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: '00007', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBe('00007'); + }); + + it('normalizes null variationId to null', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: null, ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBeNull(); + }); + + it('normalizes empty-string variationId to null', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: '', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBeNull(); + }); + + it('normalizes whitespace variationId to null', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: ' ', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBeNull(); + }); + + it('normalizes non-numeric variationId to null', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: 'variation_a', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBeNull(); + }); + + it('normalizes negative numeric variationId to null', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: '-1', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBeNull(); + }); + + it('normalizes decimal variationId to null', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: '1.5', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBeNull(); + }); + }); + + // --------------------------------------------------------------------------- + // FR-005: Uniform application across all decision types + // --------------------------------------------------------------------------- + describe('uniform application across decision types (FR-005)', () => { + const ruleTypes = ['experiment', 'feature-test', 'rollout', 'holdout']; + + ruleTypes.forEach((ruleType) => { + it(`normalizes campaign_id identically for ruleType=${ruleType}`, () => { + const decision = getDecision( + makeImpression({ layerId: 'bad', experimentId: '67890', variationId: '111', ruleType }) + ); + expect(decision.campaign_id).toBe('67890'); + }); + + it(`normalizes variation_id identically for ruleType=${ruleType}`, () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: 'bad', ruleType }) + ); + expect(decision.variation_id).toBeNull(); + }); + }); + + it('produces byte-equivalent campaign_id for the same invalid input across all rule types', () => { + const outputs = ruleTypes.map((ruleType) => + getDecision( + makeImpression({ layerId: null, experimentId: '67890', variationId: '111', ruleType }) + ).campaign_id + ); + expect(new Set(outputs).size).toBe(1); + expect(outputs[0]).toBe('67890'); + }); + }); + + // --------------------------------------------------------------------------- + // FR-006: Never drop, defer, or fail event dispatch + // --------------------------------------------------------------------------- + describe('event dispatch resilience (FR-006)', () => { + it('does not throw on all-invalid inputs', () => { + expect(() => + makeEventBatch([ + makeImpression({ layerId: null, experimentId: null, variationId: null, ruleType: 'holdout' }), + ]) + ).not.toThrow(); + }); + + it('still produces a single visitor + snapshot when ids are invalid', () => { + const batch = makeEventBatch([ + makeImpression({ layerId: null, experimentId: null, variationId: null, ruleType: 'rollout' }), + ]); + expect(batch.visitors).toHaveLength(1); + expect(batch.visitors[0].snapshots).toHaveLength(1); + expect(batch.visitors[0].snapshots[0].decisions).toHaveLength(1); + expect(batch.visitors[0].snapshots[0].events).toHaveLength(1); + }); + }); + + // --------------------------------------------------------------------------- + // FR-007: Normalization path must not log + // --------------------------------------------------------------------------- + describe('silent normalization (FR-007)', () => { + it('does not write to console.warn or console.error on invalid inputs', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + makeEventBatch([ + makeImpression({ layerId: 'abc', experimentId: 'def', variationId: 'ghi', ruleType: 'experiment' }), + ]); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + }); + + // --------------------------------------------------------------------------- + // FR-008: Cross-SDK byte-equivalent wire output for the same input + // --------------------------------------------------------------------------- + describe('byte-equivalent output (FR-008)', () => { + it('produces identical JSON for two identical event inputs', () => { + const e1 = makeImpression({ + layerId: 'bad', + experimentId: '67890', + variationId: 'bad', + ruleType: 'experiment', + }); + const e2 = makeImpression({ + layerId: 'bad', + experimentId: '67890', + variationId: 'bad', + ruleType: 'experiment', + }); + const out1 = JSON.stringify(makeEventBatch([e1])); + const out2 = JSON.stringify(makeEventBatch([e2])); + expect(out1).toBe(out2); + }); + }); + + // --------------------------------------------------------------------------- + // FR-009: entity_id on impression events equals campaign_id byte-for-byte + // --------------------------------------------------------------------------- + describe('impression entity_id equals campaign_id byte-for-byte (FR-009)', () => { + const cases: Array<{ + name: string; + layerId: string | null; + experimentId: string | null; + }> = [ + { name: 'valid layerId', layerId: '12345', experimentId: '67890' }, + { name: 'invalid layerId falls back to experiment_id', layerId: 'bad', experimentId: '67890' }, + { name: 'null layerId falls back to experiment_id', layerId: null, experimentId: '67890' }, + { name: 'both invalid -> null', layerId: 'bad', experimentId: 'also_bad' }, + { name: 'both null -> null', layerId: null, experimentId: null }, + { name: 'layerId leading zeros preserved', layerId: '00099', experimentId: '67890' }, + ]; + + cases.forEach(({ name, layerId, experimentId }) => { + it(`entity_id === campaign_id (${name})`, () => { + const event = makeImpression({ layerId, experimentId, variationId: '111', ruleType: 'experiment' }); + const decision = getDecision(event); + const snapshotEvent = getSnapshotEvent(event); + expect(snapshotEvent.entity_id).toBe(decision.campaign_id); + }); + }); + }); + + // --------------------------------------------------------------------------- + // FR-010: Conversion events derive entity_id from a different source + // --------------------------------------------------------------------------- + describe('conversion entity_id is not normalized (FR-010)', () => { + const makeConversion = (eventId: string | null): ConversionEvent => ({ + type: 'conversion', + timestamp: 69, + uuid: 'uuid', + context: { ...baseContext }, + user: { ...baseUser, attributes: [] }, + event: { id: eventId, key: 'event-key' }, + tags: undefined, + revenue: null, + value: null, + }); + + it('passes through a non-numeric conversion event id unchanged', () => { + const batch = makeEventBatch([makeConversion('event-id-string')]); + expect(batch.visitors[0].snapshots[0].events[0].entity_id).toBe('event-id-string'); + }); + + it('passes through null conversion event id unchanged', () => { + const batch = makeEventBatch([makeConversion(null)]); + expect(batch.visitors[0].snapshots[0].events[0].entity_id).toBeNull(); + }); + + it('passes through a numeric conversion event id unchanged', () => { + const batch = makeEventBatch([makeConversion('42')]); + expect(batch.visitors[0].snapshots[0].events[0].entity_id).toBe('42'); + }); + }); +}); diff --git a/lib/event_processor/event_builder/log_event.ts b/lib/event_processor/event_builder/log_event.ts index 1d13bb5fb..e71b879f7 100644 --- a/lib/event_processor/event_builder/log_event.ts +++ b/lib/event_processor/event_builder/log_event.ts @@ -160,6 +160,51 @@ function makeConversionSnapshot(conversion: ConversionEvent): Snapshot { } } +/** + * FSSDK-12813: Numeric-string validator used to normalize decision-event IDs. + * + * Returns true if value is a non-empty string consisting entirely of decimal + * digits [0-9]. Leading zeros are allowed. Whitespace, negatives, decimals, + * exponents, non-string types, null, and undefined are all invalid. + */ +function isNumericString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && /^[0-9]+$/.test(value); +} + +/** + * FSSDK-12813: Normalize a campaign_id / entity_id field. + * + * Rule (FR-001/FR-002, FR-009): if the provided id is a non-empty + * numeric-string return it unchanged; otherwise substitute experimentId + * when experimentId itself is a non-empty numeric-string. If neither is + * valid, return null so the wire payload is byte-equivalent across SDKs. + * + * Applies uniformly to ALL decision types (experiment, feature test, + * rollout, holdout) — there is no per-type branching here (FR-005). + * Does not drop or fail event dispatch (FR-006) and does not log (FR-007). + */ +function normalizeCampaignId(id: unknown, experimentId: unknown): string | null { + if (isNumericString(id)) { + return id; + } + if (isNumericString(experimentId)) { + return experimentId; + } + return null; +} + +/** + * FSSDK-12813: Normalize a variation_id field. + * + * Rule (FR-003/FR-004): if the provided id is a non-empty numeric-string + * return it unchanged; otherwise substitute null. Applies uniformly to ALL + * decision types (FR-005). Does not drop or fail event dispatch (FR-006) + * and does not log (FR-007). + */ +function normalizeVariationId(id: unknown): string | null { + return isNumericString(id) ? id : null; +} + function makeDecisionSnapshot(event: ImpressionEvent): Snapshot { const { layer, experiment, variation, ruleKey, flagKey, ruleType, enabled, cmabUuid } = event const layerId = layer ? layer.id : null @@ -167,12 +212,18 @@ function makeDecisionSnapshot(event: ImpressionEvent): Snapshot { const variationId = variation?.id ?? '' const variationKey = variation ? variation.key : '' + // FSSDK-12813: Normalize decision-event IDs uniformly across all decision + // types (experiment, feature test, rollout, holdout). entity_id on the + // impression event MUST equal decisions[].campaign_id byte-for-byte (FR-009). + const normalizedCampaignId = normalizeCampaignId(layerId, experimentId); + const normalizedVariationId = normalizeVariationId(variationId); + return { decisions: [ { - campaign_id: layerId, + campaign_id: normalizedCampaignId, experiment_id: experimentId, - variation_id: variationId, + variation_id: normalizedVariationId, metadata: { flag_key: flagKey, rule_key: ruleKey, @@ -185,7 +236,7 @@ function makeDecisionSnapshot(event: ImpressionEvent): Snapshot { ], events: [ { - entity_id: layerId, + entity_id: normalizedCampaignId, timestamp: event.timestamp, key: ACTIVATE_EVENT_KEY, uuid: event.uuid, diff --git a/lib/optimizely/index.tests.js b/lib/optimizely/index.tests.js index 437749cfb..83848696f 100644 --- a/lib/optimizely/index.tests.js +++ b/lib/optimizely/index.tests.js @@ -6690,7 +6690,8 @@ describe('lib/optimizely', function() { { campaign_id: null, experiment_id: '', - variation_id: '', + // FSSDK-12813: empty/non-numeric variation_id normalizes to null on the wire. + variation_id: null, metadata: { flag_key: 'test_feature', rule_key: '', From 28ec399b8fea416cbe8c455dd496dce7e3ef38b5 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 25 Jun 2026 11:07:11 -0700 Subject: [PATCH 2/6] [FSSDK-12813] Relax campaign_id/entity_id validation to non-empty string per updated spec --- .../event_builder/log_event.spec.ts | 82 +++++++++++-------- .../event_builder/log_event.ts | 37 ++++++--- 2 files changed, 75 insertions(+), 44 deletions(-) diff --git a/lib/event_processor/event_builder/log_event.spec.ts b/lib/event_processor/event_builder/log_event.spec.ts index a0176049f..b14d8fdde 100644 --- a/lib/event_processor/event_builder/log_event.spec.ts +++ b/lib/event_processor/event_builder/log_event.spec.ts @@ -887,10 +887,12 @@ describe('buildLogEvent', () => { * These tests pin the normalization contract for decisions[].campaign_id, * decisions[].variation_id, and events[].entity_id on impression events: * - * - campaign_id MUST be a non-empty decimal-digit string; if not, fall back - * to experiment_id; if experiment_id is also invalid, emit null. + * - campaign_id MUST be a non-empty string (any character content; IDs + * may be opaque, e.g. "default-12345", "layer_abc"); if empty/null/ + * missing, fall back to experiment_id; if experiment_id is also empty/ + * null, emit null. * - variation_id MUST be a non-empty decimal-digit string OR null; any - * invalid input normalizes to null. + * invalid input (empty, whitespace, non-numeric) normalizes to null. * - events[].entity_id (impression only) follows the same rule as * campaign_id and MUST equal decisions[].campaign_id byte-for-byte. * - The rule applies uniformly across all decision types (experiment, @@ -960,65 +962,73 @@ describe('decision event ID normalization (FSSDK-12813)', () => { expect(decision.campaign_id).toBe('00042'); }); - it('substitutes experiment_id when layerId is null', () => { + it('preserves an opaque non-numeric layerId unchanged', () => { + // FSSDK-12813: campaign_id contract is "any non-empty string". Opaque + // IDs like "layer_abc" or "default-12345" pass through unchanged. const decision = getDecision( - makeImpression({ layerId: null, experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + makeImpression({ layerId: 'layer_abc', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) ); - expect(decision.campaign_id).toBe('67890'); + expect(decision.campaign_id).toBe('layer_abc'); }); - it('substitutes experiment_id when layerId is an empty string', () => { + it('preserves a layerId with a negative sign unchanged', () => { + // FSSDK-12813: Any non-empty string is valid for campaign_id. const decision = getDecision( - makeImpression({ layerId: '', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + makeImpression({ layerId: '-123', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) ); - expect(decision.campaign_id).toBe('67890'); + expect(decision.campaign_id).toBe('-123'); }); - it('substitutes experiment_id when layerId is whitespace', () => { + it('preserves a decimal-formatted layerId unchanged', () => { + // FSSDK-12813: Any non-empty string is valid for campaign_id. const decision = getDecision( - makeImpression({ layerId: ' ', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + makeImpression({ layerId: '123.45', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) ); - expect(decision.campaign_id).toBe('67890'); + expect(decision.campaign_id).toBe('123.45'); }); - it('substitutes experiment_id when layerId is a non-numeric string', () => { + it('preserves an exponential-notation layerId unchanged', () => { + // FSSDK-12813: Any non-empty string is valid for campaign_id. const decision = getDecision( - makeImpression({ layerId: 'layer_abc', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + makeImpression({ layerId: '1e5', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) ); - expect(decision.campaign_id).toBe('67890'); + expect(decision.campaign_id).toBe('1e5'); }); - it('substitutes experiment_id when layerId contains negative sign', () => { + it('preserves a whitespace-only layerId unchanged', () => { + // FSSDK-12813: Whitespace is a non-empty string; only empty string, + // null, and undefined trigger the experiment_id fallback. const decision = getDecision( - makeImpression({ layerId: '-123', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + makeImpression({ layerId: ' ', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) ); - expect(decision.campaign_id).toBe('67890'); + expect(decision.campaign_id).toBe(' '); }); - it('substitutes experiment_id when layerId is a decimal', () => { + it('substitutes experiment_id when layerId is null', () => { const decision = getDecision( - makeImpression({ layerId: '123.45', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + makeImpression({ layerId: null, experimentId: '67890', variationId: '111', ruleType: 'experiment' }) ); expect(decision.campaign_id).toBe('67890'); }); - it('substitutes experiment_id when layerId is in exponential notation', () => { + it('substitutes experiment_id when layerId is an empty string', () => { const decision = getDecision( - makeImpression({ layerId: '1e5', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + makeImpression({ layerId: '', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) ); expect(decision.campaign_id).toBe('67890'); }); - it('emits null when both layerId and experiment_id are invalid', () => { + it('substitutes an opaque experiment_id when layerId is null', () => { + // FSSDK-12813: experiment_id fallback also accepts any non-empty string. const decision = getDecision( - makeImpression({ layerId: null, experimentId: null, variationId: '111', ruleType: 'experiment' }) + makeImpression({ layerId: null, experimentId: 'exp_42', variationId: '111', ruleType: 'experiment' }) ); - expect(decision.campaign_id).toBeNull(); + expect(decision.campaign_id).toBe('exp_42'); }); - it('emits null when both layerId and experiment_id are non-numeric strings', () => { + it('emits null when both layerId and experiment_id are null', () => { const decision = getDecision( - makeImpression({ layerId: 'abc', experimentId: 'exp_42', variationId: '111', ruleType: 'experiment' }) + makeImpression({ layerId: null, experimentId: null, variationId: '111', ruleType: 'experiment' }) ); expect(decision.campaign_id).toBeNull(); }); @@ -1100,8 +1110,10 @@ describe('decision event ID normalization (FSSDK-12813)', () => { ruleTypes.forEach((ruleType) => { it(`normalizes campaign_id identically for ruleType=${ruleType}`, () => { + // FSSDK-12813: null layerId triggers fallback to experiment_id; the + // fallback fires identically regardless of rule type. const decision = getDecision( - makeImpression({ layerId: 'bad', experimentId: '67890', variationId: '111', ruleType }) + makeImpression({ layerId: null, experimentId: '67890', variationId: '111', ruleType }) ); expect(decision.campaign_id).toBe('67890'); }); @@ -1173,14 +1185,17 @@ describe('decision event ID normalization (FSSDK-12813)', () => { // --------------------------------------------------------------------------- describe('byte-equivalent output (FR-008)', () => { it('produces identical JSON for two identical event inputs', () => { + // FSSDK-12813: identical inputs must produce identical wire output. + // layerId here is a valid non-empty string (passes through unchanged); + // variationId is non-numeric (normalizes to null). const e1 = makeImpression({ - layerId: 'bad', + layerId: 'layer_abc', experimentId: '67890', variationId: 'bad', ruleType: 'experiment', }); const e2 = makeImpression({ - layerId: 'bad', + layerId: 'layer_abc', experimentId: '67890', variationId: 'bad', ruleType: 'experiment', @@ -1200,10 +1215,11 @@ describe('decision event ID normalization (FSSDK-12813)', () => { layerId: string | null; experimentId: string | null; }> = [ - { name: 'valid layerId', layerId: '12345', experimentId: '67890' }, - { name: 'invalid layerId falls back to experiment_id', layerId: 'bad', experimentId: '67890' }, + { name: 'valid numeric layerId', layerId: '12345', experimentId: '67890' }, + { name: 'opaque non-numeric layerId preserved', layerId: 'layer_abc', experimentId: '67890' }, + { name: 'empty-string layerId falls back to experiment_id', layerId: '', experimentId: '67890' }, { name: 'null layerId falls back to experiment_id', layerId: null, experimentId: '67890' }, - { name: 'both invalid -> null', layerId: 'bad', experimentId: 'also_bad' }, + { name: 'both empty/null -> null', layerId: '', experimentId: '' }, { name: 'both null -> null', layerId: null, experimentId: null }, { name: 'layerId leading zeros preserved', layerId: '00099', experimentId: '67890' }, ]; diff --git a/lib/event_processor/event_builder/log_event.ts b/lib/event_processor/event_builder/log_event.ts index e71b879f7..8313925bc 100644 --- a/lib/event_processor/event_builder/log_event.ts +++ b/lib/event_processor/event_builder/log_event.ts @@ -161,7 +161,19 @@ function makeConversionSnapshot(conversion: ConversionEvent): Snapshot { } /** - * FSSDK-12813: Numeric-string validator used to normalize decision-event IDs. + * FSSDK-12813: Non-empty string validator used to normalize campaign_id and + * entity_id (decision-event ID fields whose contract is "any non-empty + * string"). IDs may be opaque, e.g. "default-12345" or "layer_abc". + * + * Returns true if value is a string of length >= 1. Any character content is + * accepted. Empty string, non-string types, null, and undefined are invalid. + */ +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +/** + * FSSDK-12813: Numeric-string validator used to normalize variation_id. * * Returns true if value is a non-empty string consisting entirely of decimal * digits [0-9]. Leading zeros are allowed. Whitespace, negatives, decimals, @@ -174,20 +186,22 @@ function isNumericString(value: unknown): value is string { /** * FSSDK-12813: Normalize a campaign_id / entity_id field. * - * Rule (FR-001/FR-002, FR-009): if the provided id is a non-empty - * numeric-string return it unchanged; otherwise substitute experimentId - * when experimentId itself is a non-empty numeric-string. If neither is - * valid, return null so the wire payload is byte-equivalent across SDKs. + * Rule (FR-001/FR-002, FR-009): if the provided id is a non-empty string + * return it unchanged (any character content is accepted — IDs may be + * opaque, e.g. "default-12345", "layer_abc"); otherwise substitute + * experimentId when experimentId itself is a non-empty string. If neither + * is valid (empty string, null, or undefined), return null so the wire + * payload is byte-equivalent across SDKs. * * Applies uniformly to ALL decision types (experiment, feature test, * rollout, holdout) — there is no per-type branching here (FR-005). * Does not drop or fail event dispatch (FR-006) and does not log (FR-007). */ function normalizeCampaignId(id: unknown, experimentId: unknown): string | null { - if (isNumericString(id)) { + if (isNonEmptyString(id)) { return id; } - if (isNumericString(experimentId)) { + if (isNonEmptyString(experimentId)) { return experimentId; } return null; @@ -196,10 +210,11 @@ function normalizeCampaignId(id: unknown, experimentId: unknown): string | null /** * FSSDK-12813: Normalize a variation_id field. * - * Rule (FR-003/FR-004): if the provided id is a non-empty numeric-string - * return it unchanged; otherwise substitute null. Applies uniformly to ALL - * decision types (FR-005). Does not drop or fail event dispatch (FR-006) - * and does not log (FR-007). + * Rule (FR-003/FR-004): variation_id retains the stricter numeric-string + * contract. If the provided id is a non-empty numeric-string return it + * unchanged; otherwise substitute null. Applies uniformly to ALL decision + * types (FR-005). Does not drop or fail event dispatch (FR-006) and does + * not log (FR-007). */ function normalizeVariationId(id: unknown): string | null { return isNumericString(id) ? id : null; From 58367d37fbfa750a1d66a91cbcd86783b4d4cc08 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 25 Jun 2026 14:30:09 -0700 Subject: [PATCH 3/6] [FSSDK-12813] Remove ticket references from code comments per cross-sdk guideline --- .../event_builder/log_event.spec.ts | 69 ++++++++----------- .../event_builder/log_event.ts | 45 ++++-------- lib/optimizely/index.tests.js | 1 - 3 files changed, 41 insertions(+), 74 deletions(-) diff --git a/lib/event_processor/event_builder/log_event.spec.ts b/lib/event_processor/event_builder/log_event.spec.ts index b14d8fdde..6a726c156 100644 --- a/lib/event_processor/event_builder/log_event.spec.ts +++ b/lib/event_processor/event_builder/log_event.spec.ts @@ -26,9 +26,8 @@ import { Region } from '../../project_config/project_config'; describe('makeEventBatch', () => { it('should build a batch with single impression event when experiment and variation are defined', () => { - // FSSDK-12813: campaign_id, experiment_id, variation_id, and entity_id - // are normalized to numeric-strings on the wire. Test fixtures use valid - // numeric IDs so the happy-path output matches expectations. + // Fixtures use valid numeric IDs so the wire output matches the + // post-normalization happy-path expectations. const impressionEvent: ImpressionEvent = { type: 'impression', timestamp: 69, @@ -128,10 +127,9 @@ describe('makeEventBatch', () => { }) it('should build a batch with simlge impression event when experiment and variation are not defined', () => { - // FSSDK-12813: When campaign_id, experiment_id, and variation_id are all + // When campaign_id, experiment_id, and variation_id are all // missing/invalid, the normalized wire output is campaign_id=null, - // variation_id=null, and entity_id MUST equal campaign_id byte-for-byte - // (FR-009). experiment_id is left as-is. + // variation_id=null, and entity_id mirrors campaign_id byte-for-byte. const impressionEvent: ImpressionEvent = { type: 'impression', timestamp: 69, @@ -190,7 +188,6 @@ describe('makeEventBatch', () => { { campaign_id: null, experiment_id: "", - // FSSDK-12813: empty/null variation_id normalizes to null. variation_id: null, metadata: { flag_key: 'flagKey1', @@ -699,8 +696,7 @@ describe('makeEventBatch', () => { attributes: [{ entityId: 'attr1-id', key: 'attr1-key', value: 'attr1-value' }], }, - // FSSDK-12813: Use numeric-string IDs so happy-path output is unchanged - // after normalization. + // Use numeric-string IDs so the happy-path wire output is unchanged. layer: { id: '11111', }, @@ -819,8 +815,8 @@ describe('makeEventBatch', () => { describe('buildLogEvent', () => { it('should select the correct URL based on the event context region', () => { - // FSSDK-12813: Use numeric-string IDs to avoid normalization side effects - // unrelated to the URL-region behavior being tested here. + // Use numeric-string IDs so normalization is a no-op here; this test + // only covers URL-region behavior. const baseEvent: ImpressionEvent = { type: 'impression', timestamp: 69, @@ -882,27 +878,24 @@ describe('buildLogEvent', () => { }); /** - * FSSDK-12813: Decision-event ID normalization tests. + * Decision-event ID normalization tests. * - * These tests pin the normalization contract for decisions[].campaign_id, + * Pins the normalization contract for decisions[].campaign_id, * decisions[].variation_id, and events[].entity_id on impression events: * - * - campaign_id MUST be a non-empty string (any character content; IDs - * may be opaque, e.g. "default-12345", "layer_abc"); if empty/null/ - * missing, fall back to experiment_id; if experiment_id is also empty/ - * null, emit null. - * - variation_id MUST be a non-empty decimal-digit string OR null; any - * invalid input (empty, whitespace, non-numeric) normalizes to null. - * - events[].entity_id (impression only) follows the same rule as - * campaign_id and MUST equal decisions[].campaign_id byte-for-byte. - * - The rule applies uniformly across all decision types (experiment, + * - campaign_id: non-empty string (opaque IDs allowed); fall back to + * experiment_id when empty/null/missing; emit null when both are + * empty/null. + * - variation_id: non-empty decimal-digit string OR null; any invalid + * input (empty, whitespace, non-numeric) normalizes to null. + * - events[].entity_id (impression only) follows the campaign_id rule + * and must equal decisions[].campaign_id byte-for-byte. + * - Rule applies uniformly across all decision types (experiment, * feature-test, rollout, holdout) — no per-type branching. - * - Conversion events derive entity_id from event.id and are left - * unchanged. - * - Event dispatch is never dropped or failed by normalization, and - * normalization never logs. + * - Conversion events derive entity_id from event.id and are unchanged. + * - Normalization never drops, fails, or logs. */ -describe('decision event ID normalization (FSSDK-12813)', () => { +describe('decision event ID normalization', () => { const baseContext = { accountId: 'accountId', projectId: 'projectId', @@ -963,8 +956,8 @@ describe('decision event ID normalization (FSSDK-12813)', () => { }); it('preserves an opaque non-numeric layerId unchanged', () => { - // FSSDK-12813: campaign_id contract is "any non-empty string". Opaque - // IDs like "layer_abc" or "default-12345" pass through unchanged. + // campaign_id contract is "any non-empty string"; opaque IDs like + // "layer_abc" or "default-12345" pass through unchanged. const decision = getDecision( makeImpression({ layerId: 'layer_abc', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) ); @@ -972,7 +965,6 @@ describe('decision event ID normalization (FSSDK-12813)', () => { }); it('preserves a layerId with a negative sign unchanged', () => { - // FSSDK-12813: Any non-empty string is valid for campaign_id. const decision = getDecision( makeImpression({ layerId: '-123', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) ); @@ -980,7 +972,6 @@ describe('decision event ID normalization (FSSDK-12813)', () => { }); it('preserves a decimal-formatted layerId unchanged', () => { - // FSSDK-12813: Any non-empty string is valid for campaign_id. const decision = getDecision( makeImpression({ layerId: '123.45', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) ); @@ -988,7 +979,6 @@ describe('decision event ID normalization (FSSDK-12813)', () => { }); it('preserves an exponential-notation layerId unchanged', () => { - // FSSDK-12813: Any non-empty string is valid for campaign_id. const decision = getDecision( makeImpression({ layerId: '1e5', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) ); @@ -996,8 +986,8 @@ describe('decision event ID normalization (FSSDK-12813)', () => { }); it('preserves a whitespace-only layerId unchanged', () => { - // FSSDK-12813: Whitespace is a non-empty string; only empty string, - // null, and undefined trigger the experiment_id fallback. + // Whitespace is a non-empty string; only empty string, null, and + // undefined trigger the experiment_id fallback. const decision = getDecision( makeImpression({ layerId: ' ', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) ); @@ -1019,7 +1009,7 @@ describe('decision event ID normalization (FSSDK-12813)', () => { }); it('substitutes an opaque experiment_id when layerId is null', () => { - // FSSDK-12813: experiment_id fallback also accepts any non-empty string. + // experiment_id fallback also accepts any non-empty string. const decision = getDecision( makeImpression({ layerId: null, experimentId: 'exp_42', variationId: '111', ruleType: 'experiment' }) ); @@ -1110,8 +1100,8 @@ describe('decision event ID normalization (FSSDK-12813)', () => { ruleTypes.forEach((ruleType) => { it(`normalizes campaign_id identically for ruleType=${ruleType}`, () => { - // FSSDK-12813: null layerId triggers fallback to experiment_id; the - // fallback fires identically regardless of rule type. + // null layerId triggers fallback to experiment_id; the fallback + // fires identically regardless of rule type. const decision = getDecision( makeImpression({ layerId: null, experimentId: '67890', variationId: '111', ruleType }) ); @@ -1185,9 +1175,8 @@ describe('decision event ID normalization (FSSDK-12813)', () => { // --------------------------------------------------------------------------- describe('byte-equivalent output (FR-008)', () => { it('produces identical JSON for two identical event inputs', () => { - // FSSDK-12813: identical inputs must produce identical wire output. - // layerId here is a valid non-empty string (passes through unchanged); - // variationId is non-numeric (normalizes to null). + // layerId here is a valid non-empty string (passes through); the + // variationId is non-numeric and normalizes to null. const e1 = makeImpression({ layerId: 'layer_abc', experimentId: '67890', diff --git a/lib/event_processor/event_builder/log_event.ts b/lib/event_processor/event_builder/log_event.ts index 8313925bc..1929f36ac 100644 --- a/lib/event_processor/event_builder/log_event.ts +++ b/lib/event_processor/event_builder/log_event.ts @@ -161,41 +161,26 @@ function makeConversionSnapshot(conversion: ConversionEvent): Snapshot { } /** - * FSSDK-12813: Non-empty string validator used to normalize campaign_id and - * entity_id (decision-event ID fields whose contract is "any non-empty - * string"). IDs may be opaque, e.g. "default-12345" or "layer_abc". - * - * Returns true if value is a string of length >= 1. Any character content is - * accepted. Empty string, non-string types, null, and undefined are invalid. + * Non-empty string validator used to normalize campaign_id and entity_id. + * IDs may be opaque, e.g. "default-12345" or "layer_abc". */ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.length > 0; } /** - * FSSDK-12813: Numeric-string validator used to normalize variation_id. - * - * Returns true if value is a non-empty string consisting entirely of decimal - * digits [0-9]. Leading zeros are allowed. Whitespace, negatives, decimals, - * exponents, non-string types, null, and undefined are all invalid. + * Numeric-string validator used to normalize variation_id. Returns true if + * value is a non-empty string of decimal digits [0-9]. Leading zeros are + * allowed. */ function isNumericString(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && /^[0-9]+$/.test(value); } /** - * FSSDK-12813: Normalize a campaign_id / entity_id field. - * - * Rule (FR-001/FR-002, FR-009): if the provided id is a non-empty string - * return it unchanged (any character content is accepted — IDs may be - * opaque, e.g. "default-12345", "layer_abc"); otherwise substitute - * experimentId when experimentId itself is a non-empty string. If neither - * is valid (empty string, null, or undefined), return null so the wire - * payload is byte-equivalent across SDKs. - * - * Applies uniformly to ALL decision types (experiment, feature test, - * rollout, holdout) — there is no per-type branching here (FR-005). - * Does not drop or fail event dispatch (FR-006) and does not log (FR-007). + * Normalize a campaign_id / entity_id field. Returns the provided id when + * it is a non-empty string; otherwise substitutes experimentId; otherwise + * returns null so the wire payload is byte-equivalent across SDKs. */ function normalizeCampaignId(id: unknown, experimentId: unknown): string | null { if (isNonEmptyString(id)) { @@ -208,13 +193,8 @@ function normalizeCampaignId(id: unknown, experimentId: unknown): string | null } /** - * FSSDK-12813: Normalize a variation_id field. - * - * Rule (FR-003/FR-004): variation_id retains the stricter numeric-string - * contract. If the provided id is a non-empty numeric-string return it - * unchanged; otherwise substitute null. Applies uniformly to ALL decision - * types (FR-005). Does not drop or fail event dispatch (FR-006) and does - * not log (FR-007). + * Normalize a variation_id field. variation_id keeps the stricter + * numeric-string contract — non-numeric / empty input normalizes to null. */ function normalizeVariationId(id: unknown): string | null { return isNumericString(id) ? id : null; @@ -227,9 +207,8 @@ function makeDecisionSnapshot(event: ImpressionEvent): Snapshot { const variationId = variation?.id ?? '' const variationKey = variation ? variation.key : '' - // FSSDK-12813: Normalize decision-event IDs uniformly across all decision - // types (experiment, feature test, rollout, holdout). entity_id on the - // impression event MUST equal decisions[].campaign_id byte-for-byte (FR-009). + // entity_id on the impression event mirrors decisions[].campaign_id + // byte-for-byte so the two fields stay wire-equivalent. const normalizedCampaignId = normalizeCampaignId(layerId, experimentId); const normalizedVariationId = normalizeVariationId(variationId); diff --git a/lib/optimizely/index.tests.js b/lib/optimizely/index.tests.js index 83848696f..d54a7bfbb 100644 --- a/lib/optimizely/index.tests.js +++ b/lib/optimizely/index.tests.js @@ -6690,7 +6690,6 @@ describe('lib/optimizely', function() { { campaign_id: null, experiment_id: '', - // FSSDK-12813: empty/non-numeric variation_id normalizes to null on the wire. variation_id: null, metadata: { flag_key: 'test_feature', From b48f44a8a3b5b0ad4e7fee721c72bb1865f28e1f Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 25 Jun 2026 19:54:29 -0700 Subject: [PATCH 4/6] [FSSDK-12813] Remove unused UserEvent import in log_event.spec --- lib/event_processor/event_builder/log_event.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/event_processor/event_builder/log_event.spec.ts b/lib/event_processor/event_builder/log_event.spec.ts index 6a726c156..fa374e27a 100644 --- a/lib/event_processor/event_builder/log_event.spec.ts +++ b/lib/event_processor/event_builder/log_event.spec.ts @@ -20,7 +20,7 @@ import { buildLogEvent, } from './log_event'; -import { ImpressionEvent, ConversionEvent, UserEvent } from './user_event'; +import { ImpressionEvent, ConversionEvent } from './user_event'; import { Region } from '../../project_config/project_config'; From 509f7b3e3e17d2ea922563087e10e975f1a3152b Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 25 Jun 2026 20:30:16 -0700 Subject: [PATCH 5/6] [FSSDK-12813] Add 2026 to copyright year in changed files --- lib/event_processor/event_builder/log_event.spec.ts | 2 +- lib/event_processor/event_builder/log_event.ts | 2 +- lib/optimizely/index.tests.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/event_processor/event_builder/log_event.spec.ts b/lib/event_processor/event_builder/log_event.spec.ts index fa374e27a..50750d508 100644 --- a/lib/event_processor/event_builder/log_event.spec.ts +++ b/lib/event_processor/event_builder/log_event.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, 2024, Optimizely + * Copyright 2022, 2024, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/lib/event_processor/event_builder/log_event.ts b/lib/event_processor/event_builder/log_event.ts index 1929f36ac..970dd62ec 100644 --- a/lib/event_processor/event_builder/log_event.ts +++ b/lib/event_processor/event_builder/log_event.ts @@ -1,5 +1,5 @@ /** - * Copyright 2021-2022, 2024, Optimizely + * Copyright 2021-2022, 2024, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/lib/optimizely/index.tests.js b/lib/optimizely/index.tests.js index d54a7bfbb..50eba745a 100644 --- a/lib/optimizely/index.tests.js +++ b/lib/optimizely/index.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2016-2025, Optimizely + * Copyright 2016-2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From 8ce653c1585ccbea0ae667ca878c0a880ab13c25 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Fri, 26 Jun 2026 09:28:49 -0700 Subject: [PATCH 6/6] [FSSDK-12813] Trigger CI