diff --git a/packages/formbricks/lib/src/survey/embedded_data.dart b/packages/formbricks/lib/src/survey/embedded_data.dart new file mode 100644 index 0000000..8589b16 --- /dev/null +++ b/packages/formbricks/lib/src/survey/embedded_data.dart @@ -0,0 +1,138 @@ +/// The in-memory Embedded Data bag. +/// +/// Context a host app attaches to future responses without tying it to a +/// trigger — `Formbricks.setEmbeddedData({'screen': 'checkout'})` once, instead +/// of repeating the same values on every possible `track(...)` call. +/// +/// Mirrors the JS SDK's store key for key, so web and mobile behave identically. +library; + +import '../common/logger.dart'; + +/// Holds the host-supplied Embedded Data for this process. +/// +/// Lifetime rules, all deliberate: +/// +/// * **In-memory, process scoped, never persisted.** Not `SharedPreferences`: +/// persisting this bag would blur the Embedded Data ↔ contact-attribute +/// boundary and create a stale-data / PII-at-rest surface. A cold app start +/// begins empty; the host re-pushes. +/// * **Snapshot at display, then frozen.** `SurveyWebView` copies the bag into +/// the survey's render options when the survey is shown, so a later +/// `setEmbeddedData` affects the next response, never the one on screen. +/// * **No filtering here.** The SDK is a dumb pipe: the survey renderer applies +/// the ingest contract — allow-list, coercion, `locked`, size caps — and logs +/// what it refuses, and the server re-runs all of it on ingest. Filtering here +/// would ship a second copy of those rules for the four mobile SDKs to drift +/// from. +/// * **Independent of `setup`.** A host legitimately pushes context before the +/// SDK finishes initializing, and silently dropping that write is the failure +/// this API exists to avoid. +/// * **No network.** Every method is a synchronous memory write, so calling it +/// on every screen change is free. Values ride the existing response payload. +class EmbeddedDataStore { + EmbeddedDataStore._(); + + /// The process-wide bag. + static final EmbeddedDataStore instance = EmbeddedDataStore._(); + + final Map _data = {}; + + /// Merges [values] into the bag — never replaces it — so refreshing a + /// volatile field (`screen`) cannot wipe the stable ones (`plan`) set at + /// launch. Per key: last write wins, and an explicit `null` removes the key. + /// + /// A key the caller simply leaves out is untouched; that is how a host skips a + /// field it has no value for this screen. `null` is the deliberate "remove + /// this" spelling, matching the JS SDK's `{ key: null }`. + /// + /// Values must be a `String`, `num`, `bool` or `DateTime` — the four scalars + /// the ingest contract can store. Anything else is logged and skipped rather + /// than thrown: a host mistake must never cost a response. A non-finite `num` + /// is refused for the same reason, and a sharper one — `jsonEncode` throws on + /// `NaN`/`Infinity`, and the payload it would refuse is the whole survey's + /// render options, so one bad value would cost the survey, not the field. + void set(Map values) { + final setKeys = []; + final removedKeys = []; + + for (final entry in values.entries) { + final value = entry.value; + if (value == null) { + _data.remove(entry.key); + removedKeys.add(entry.key); + continue; + } + if (value is num && !value.isFinite) { + Logger.error( + 'setEmbeddedData: "${entry.key}" is not a finite number — ' + 'the key was skipped', + ); + continue; + } + if (value is! String && + value is! num && + value is! bool && + value is! DateTime) { + Logger.error( + 'setEmbeddedData: "${entry.key}" is a ${value.runtimeType}, which ' + 'cannot be stored — the key was skipped', + ); + continue; + } + _data[entry.key] = value; + setKeys.add(entry.key); + } + + _traceSet(setKeys, removedKeys); + } + + /// A success trace, because the bag is otherwise invisible: it lives in memory + /// (nothing in `SharedPreferences` to inspect) and the API has no getter, so + /// without this line a host wiring up `setEmbeddedData` gets no confirmation + /// until a survey happens to display. Debug level, so it is silent unless + /// `setup` was given [LogLevel.debug]. + /// + /// Keys only, never values: the documented use of this bag includes hashed + /// identity fields, and `Logger` is explicitly a no-PII channel. + void _traceSet(List setKeys, List removedKeys) { + final removed = + removedKeys.isEmpty ? '' : ', removed [${removedKeys.join(', ')}]'; + Logger.debug( + 'setEmbeddedData: set [${setKeys.join(', ')}]$removed — the bag now ' + 'holds [${_data.keys.join(', ')}]. Keys land on a response only if the ' + 'survey declares them as ingested Embedded Data fields.', + ); + } + + /// Removes one [key]. A key that is not set is a no-op. + void remove(String key) { + _data.remove(key); + Logger.debug( + 'clearEmbeddedData: removed "$key" — the bag now holds ' + '[${_data.keys.join(', ')}]', + ); + } + + /// Removes everything — logout, or a hard context switch. + void clear() { + final clearedCount = _data.length; + _data.clear(); + Logger.debug( + 'clearEmbeddedData: cleared the whole bag ($clearedCount keys)', + ); + } + + /// A detached, JSON-safe copy for the display-time snapshot: mutating the bag + /// after a survey has rendered must not reach that survey's response. + /// + /// `DateTime` is serialized as ISO 8601, which is what the renderer's ingest + /// contract accepts for a `date` field. Everything else `jsonEncode` already + /// handles. + Map snapshot() => { + for (final entry in _data.entries) + entry.key: entry.value is DateTime + ? (entry.value as DateTime).toUtc().toIso8601String() + : entry.value, + }; +} diff --git a/packages/formbricks/lib/src/user/user.dart b/packages/formbricks/lib/src/user/user.dart index 563b307..903ad33 100644 --- a/packages/formbricks/lib/src/user/user.dart +++ b/packages/formbricks/lib/src/user/user.dart @@ -10,6 +10,7 @@ import '../common/config.dart'; import '../common/logger.dart'; import '../common/result.dart'; import '../common/setup.dart'; +import '../survey/embedded_data.dart'; import '../types/errors.dart'; import 'update_queue.dart'; @@ -45,6 +46,11 @@ Future> setUserId( // the new user. q.clear(); await tearDown(config: cfg); + // An identity switch: the ambient Embedded Data bag may carry the previous + // user's context, which must not ride onto the next user's responses on a + // shared device. First-time identification keeps the bag — a host + // legitimately pushes context before it knows who the user is. + EmbeddedDataStore.instance.clear(); } q.updateUserId(userId); @@ -65,5 +71,8 @@ Future> logout({ // after logout. (queue ?? UpdateQueue.instance).clear(); await tearDown(config: config ?? FormbricksConfig.instance); + // Same identity-switch rule as setUserId: logout must not let the previous + // user's ambient context leak onto whoever uses the app next. + EmbeddedDataStore.instance.clear(); return const Result.ok(null); } diff --git a/packages/formbricks/lib/src/widgets/formbricks_widget.dart b/packages/formbricks/lib/src/widgets/formbricks_widget.dart index 47bd81e..c8825d6 100644 --- a/packages/formbricks/lib/src/widgets/formbricks_widget.dart +++ b/packages/formbricks/lib/src/widgets/formbricks_widget.dart @@ -13,6 +13,7 @@ import '../common/logger.dart'; import '../common/result.dart'; import '../common/setup.dart' as setup_internal; import '../survey/action.dart' as action; +import '../survey/embedded_data.dart'; import '../survey/survey_store.dart'; import '../types/errors.dart'; import '../types/survey.dart'; @@ -22,6 +23,22 @@ import 'default_webview_host.dart'; import 'survey_webview.dart'; import 'webview_navigation.dart'; +/// The type of the "no argument" marker for [Formbricks.clearEmbeddedData]. +/// +/// Library-private on purpose. A default parameter value must be a +/// compile-time constant, and Dart canonicalizes const instances — so a +/// `const Object()` marker is `identical` to *every* `const Object()` in the +/// program, and `clearEmbeddedData(const Object())` from host code would wipe +/// the whole bag instead of being refused. Naming a private type is the one +/// thing a caller outside this library cannot do, so a const instance of +/// `_ClearWholeBag` is both a valid default value and unforgeable. +class _ClearWholeBag { + const _ClearWholeBag(); +} + +/// The "no argument" marker for [Formbricks.clearEmbeddedData]. +const Object _clearWholeBag = _ClearWholeBag(); + /// The Formbricks SDK facade and drop-in host widget. /// /// Place `Formbricks(appUrl: ..., workspaceId: ...)` in your widget tree: it @@ -154,6 +171,66 @@ class Formbricks extends StatefulWidget { ); } + /// Attaches Embedded Data to future responses without tying it to a trigger. + /// + /// Merges into an in-memory bag — last write wins per key, and an explicit + /// `null` removes a key. Values land only on the survey's declared *ingested* + /// fields; anything else is dropped and logged by the survey renderer, never + /// fatal. Values must be a `String`, `num`, `bool` or `DateTime`. + /// + /// Deliberately synchronous and **not** routed through the command queue, + /// unlike the methods above: a host that pushes context at launch must not + /// have that value silently dropped because `setup` had not finished. The bag + /// is pure memory — nothing here needs the SDK to be running, and calling it + /// on every screen change is free. + /// + /// The bag is snapshotted when a survey is displayed and frozen for its + /// lifetime, so a value set while a survey is on screen reaches the *next* + /// response, not that one. It is never persisted: a cold app start begins + /// empty and the host re-pushes. + /// + /// ```dart + /// Formbricks.setEmbeddedData({'plan': 'pro', 'seats': 25}); + /// Formbricks.setEmbeddedData({'screen': null}); // removes the key + /// ``` + static void setEmbeddedData(Map data) { + EmbeddedDataStore.instance.set(data); + } + + /// Removes one Embedded Data key, or the whole bag when called with no + /// argument — logout, or a hard context switch. + /// + /// ```dart + /// Formbricks.clearEmbeddedData('plan'); // one key + /// Formbricks.clearEmbeddedData(); // everything + /// ``` + /// + /// The [key] is typed `Object?` around a private sentinel rather than as a + /// plain `String?`, so that "called with no argument" and "called with a key + /// that evaluated to null" stay different things — the same distinction the + /// JS SDK draws by argument count. A host that reads the key from its own + /// state (`clearEmbeddedData(prefs['fieldToClear'])`) must not wipe the whole + /// bag when that state is empty; that call is a logged no-op. + /// + /// The sentinel is a const instance of the private [_ClearWholeBag] rather + /// than a `const Object()`: const instances are canonicalized, so the latter + /// would make `clearEmbeddedData(const Object())` from host code an accidental + /// clear-everything. + static void clearEmbeddedData([Object? key = _clearWholeBag]) { + if (identical(key, _clearWholeBag)) { + EmbeddedDataStore.instance.clear(); + return; + } + if (key is! String) { + Logger.error( + 'clearEmbeddedData: expected a field name — nothing was cleared ' + '(call with no argument to clear everything)', + ); + return; + } + EmbeddedDataStore.instance.remove(key); + } + /// Logs the current user out, resetting user state to anonymous /// (`checkSetup: true`). static Future> logout() { diff --git a/packages/formbricks/lib/src/widgets/survey_html.dart b/packages/formbricks/lib/src/widgets/survey_html.dart index 64e491b..0416691 100644 --- a/packages/formbricks/lib/src/widgets/survey_html.dart +++ b/packages/formbricks/lib/src/widgets/survey_html.dart @@ -28,6 +28,7 @@ class SurveyHtmlOptions { this.placement, this.clickOutside, this.overlay, + this.hiddenFieldsRecord = const {}, }); /// The survey to render. @@ -60,6 +61,13 @@ class SurveyHtmlOptions { /// Overlay mode, when set. final String? overlay; + /// The Embedded Data bag, snapshotted when the survey is displayed and frozen + /// for its life. Passed raw and unfiltered: the ingest contract (allow-list, + /// coercion, `locked`, size caps) lives in the renderer, so all four mobile + /// SDKs inherit the same rules without each shipping a copy, and the server + /// re-runs all of it on ingest. + final Map hiddenFieldsRecord; + /// The `renderSurvey` options object. Map toRenderOptions() => { 'workspaceId': workspaceId, @@ -73,6 +81,7 @@ class SurveyHtmlOptions { if (clickOutside != null) 'clickOutside': clickOutside, if (overlay != null) 'overlay': overlay, 'isWebEnvironment': false, + 'hiddenFieldsRecord': hiddenFieldsRecord, }; } diff --git a/packages/formbricks/lib/src/widgets/survey_webview.dart b/packages/formbricks/lib/src/widgets/survey_webview.dart index 19eec32..4049bd5 100644 --- a/packages/formbricks/lib/src/widgets/survey_webview.dart +++ b/packages/formbricks/lib/src/widgets/survey_webview.dart @@ -16,6 +16,7 @@ import '../common/config.dart'; import '../common/filter_surveys.dart'; import '../common/logger.dart'; import '../common/utils.dart'; +import '../survey/embedded_data.dart'; import '../survey/survey_store.dart'; import '../types/config.dart'; import '../types/survey.dart'; @@ -164,6 +165,11 @@ class _SurveyWebViewState extends State { clickOutside: overwrites?.clickOutsideClose ?? _asBool(settings['clickOutsideClose']), overlay: overlay, + // Read here and nowhere else: `_present` runs when the survey is + // actually shown, after any configured delay, and the html it builds is + // handed to the WebView once. A value set after this point reaches the + // next response, never the one on screen. + hiddenFieldsRecord: EmbeddedDataStore.instance.snapshot(), ), ); diff --git a/packages/formbricks/test/survey/embedded_data_test.dart b/packages/formbricks/test/survey/embedded_data_test.dart new file mode 100644 index 0000000..3f444d5 --- /dev/null +++ b/packages/formbricks/test/survey/embedded_data_test.dart @@ -0,0 +1,298 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks/formbricks.dart'; +import 'package:formbricks/src/common/logger.dart'; +import 'package:formbricks/src/survey/embedded_data.dart'; + +/// Captures the lines `Logger` writes while [body] runs. `Logger` routes through +/// `debugPrint`, which `flutter_test` lets us swap for the duration of a test. +List _captureLogs(void Function() body) { + final lines = []; + final original = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) lines.add(message); + }; + try { + body(); + } finally { + debugPrint = original; + } + return lines; +} + +/// The Embedded Data bag (ENG-1844 / ENG-2472): host-supplied context attached +/// to future responses without tying it to a trigger. These pin the contract all +/// four SDKs share, so a divergence here is a divergence from the JS SDK too. +void main() { + final store = EmbeddedDataStore.instance; + + setUp(() { + Logger.resetInstance(); + store.clear(); + }); + + tearDown(store.clear); + + group('merge semantics', () { + test('merges instead of replacing: setting one key keeps the others', () { + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + Formbricks.setEmbeddedData({'screen': 'checkout'}); + + expect(store.snapshot(), {'plan': 'pro', 'screen': 'checkout'}); + }); + + test('null drops the key', () { + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + Formbricks.setEmbeddedData({'screen': null}); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + + test('last write wins per key', () { + Formbricks.setEmbeddedData({'plan': 'free'}); + Formbricks.setEmbeddedData({'plan': 'pro'}); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + + test('omitted keys are untouched', () { + // Dart has no `undefined`, so "skip this field" is spelled by leaving the + // key out — and that must not disturb what an earlier call set. `null` is + // the explicit "remove" spelling. + Formbricks.setEmbeddedData({'plan': 'pro'}); + Formbricks.setEmbeddedData({'seats': 4}); + + expect(store.snapshot(), {'plan': 'pro', 'seats': 4}); + }); + }); + + group('clearing', () { + test('clearEmbeddedData(key) removes one key', () { + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + + Formbricks.clearEmbeddedData('screen'); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + + test('clearing an unset key is a no-op', () { + Formbricks.setEmbeddedData({'plan': 'pro'}); + + Formbricks.clearEmbeddedData('neverSet'); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + + test('clearEmbeddedData() with no argument removes everything', () { + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + + Formbricks.clearEmbeddedData(); + + expect(store.snapshot(), isEmpty); + }); + + test('clearEmbeddedData(null) is a no-op, NOT a full clear', () { + // One keystroke from the no-argument form and the opposite behaviour. A + // host reading the key from its own state must not wipe the whole bag + // when that state is empty — the sentinel default is what keeps the two + // apart, the same distinction the JS SDK draws by argument count. + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + + Formbricks.clearEmbeddedData(null); + + expect(store.snapshot(), {'plan': 'pro', 'screen': 'product'}); + }); + + test('clearEmbeddedData with a non-string key is a no-op', () { + Formbricks.setEmbeddedData({'plan': 'pro'}); + + Formbricks.clearEmbeddedData(42); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + }); + + group('value types', () { + test('every scalar survives, with DateTime as ISO 8601', () { + final signedUpAt = DateTime.utc(2026, 8, 20, 10); + + Formbricks.setEmbeddedData({ + 'plan': 'pro', + 'seats': 25, + 'score': 9.5, + 'isTrial': false, + 'signedUpAt': signedUpAt, + }); + + expect(store.snapshot(), { + 'plan': 'pro', + 'seats': 25, + 'score': 9.5, + 'isTrial': false, + // ISO 8601 is what the renderer's ingest contract accepts for a `date`. + 'signedUpAt': '2026-08-20T10:00:00.000Z', + }); + }); + + test('a local DateTime is normalized to UTC, not left ambiguous', () { + final local = DateTime.utc(2026, 8, 20, 10).toLocal(); + + Formbricks.setEmbeddedData({'signedUpAt': local}); + + expect(store.snapshot()['signedUpAt'], '2026-08-20T10:00:00.000Z'); + }); + + test('a snapshot is always JSON-encodable', () { + // The snapshot is embedded in the survey WebView's render options. If + // jsonEncode ever threw, the failure would not be a missing field — it + // would be no survey at all. + Formbricks.setEmbeddedData({ + 'plan': 'pro', + 'seats': 25, + 'isTrial': true, + 'signedUpAt': DateTime.now(), + }); + + expect(() => jsonEncode(store.snapshot()), returnsNormally); + }); + + test('a non-finite number is skipped rather than costing the survey', () { + // THE guard: jsonEncode throws on NaN/Infinity, and the payload it would + // refuse is the whole survey's render options. + Formbricks.setEmbeddedData({'plan': 'pro'}); + + Formbricks.setEmbeddedData({ + 'broken': double.nan, + 'alsoBroken': double.infinity, + }); + + expect(store.snapshot(), {'plan': 'pro'}); + expect(() => jsonEncode(store.snapshot()), returnsNormally); + }); + + test('an unsupported value type is skipped, never thrown', () { + Formbricks.setEmbeddedData({'plan': 'pro'}); + + expect( + () => Formbricks.setEmbeddedData({ + 'items': ['a', 'b'], + 'nested': {'a': 1}, + }), + returnsNormally, + ); + expect(store.snapshot(), {'plan': 'pro'}); + }); + }); + + group('lifetime', () { + test('snapshot is detached: later writes do not reach an earlier one', () { + // What "a value set after a survey is displayed does not change that + // response" rests on — the render options hold this map for the life of + // the survey. + Formbricks.setEmbeddedData({'plan': 'pro'}); + final snapshot = store.snapshot(); + + Formbricks.setEmbeddedData({'plan': 'enterprise', 'extra': 'later'}); + + expect(snapshot, {'plan': 'pro'}); + }); + + test('works before setup', () { + // Deliberately unlike the queued methods: a host that pushes context at + // launch must not have the value dropped because setup had not finished. + Formbricks.setEmbeddedData({'plan': 'pro'}); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + }); + + group('the debug success trace — the bag\'s only success feedback', () { + setUp(() => Logger.configure(level: LogLevel.debug)); + + test('a successful set logs the keys — keys only, never values', () { + final lines = _captureLogs( + () => Formbricks.setEmbeddedData({ + 'plan': 'pro', + 'hashed_email': 's3cret-hash', + }), + ); + + expect(lines, hasLength(1)); + expect(lines.single, contains('set [plan, hashed_email]')); + expect(lines.single, contains('the bag now holds [plan, hashed_email]')); + // The bag's documented use includes hashed identity fields; a value must + // never reach a log line. + expect(lines.single, isNot(contains('pro'))); + expect(lines.single, isNot(contains('s3cret-hash'))); + }); + + test('a null removal shows up as removed, not set', () { + Formbricks.setEmbeddedData({'plan': 'pro'}); + + final lines = _captureLogs( + () => Formbricks.setEmbeddedData({'plan': null, 'screen': 'checkout'}), + ); + + expect(lines.single, contains('set [screen]')); + expect(lines.single, contains('removed [plan]')); + expect(lines.single, contains('the bag now holds [screen]')); + }); + + test('a skipped value appears in neither list', () { + final lines = _captureLogs( + () => Formbricks.setEmbeddedData({'plan': 'pro', 'x': double.nan}), + ); + + // The refusal logs its own error line; the trace is the second. + expect(lines, hasLength(2)); + expect(lines.last, contains('set [plan]')); + expect(lines.last, isNot(contains('[plan, x]'))); + }); + + test('clearEmbeddedData traces both forms', () { + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + + final removedLines = + _captureLogs(() => Formbricks.clearEmbeddedData('plan')); + expect(removedLines.single, contains('removed "plan"')); + expect(removedLines.single, contains('the bag now holds [screen]')); + + final clearedLines = _captureLogs(Formbricks.clearEmbeddedData); + expect(clearedLines.single, contains('cleared the whole bag (1 keys)')); + }); + + test('the trace is silent at the default error level', () { + Logger.resetInstance(); + + final lines = _captureLogs( + () => Formbricks.setEmbeddedData({'plan': 'pro'}), + ); + + expect(lines, isEmpty); + }); + }); + + group('the clear-everything sentinel', () { + test('an outside const Object() cannot forge it', () { + // Dart canonicalizes const instances, so a `const Object()` sentinel would + // be identical to every `const Object()` in the program and this call + // would wipe the bag. The sentinel is a const instance of a private class + // instead, which host code cannot name. + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + + Formbricks.clearEmbeddedData(const Object()); + + expect(store.snapshot(), {'plan': 'pro', 'screen': 'product'}); + }); + + test('a non-const Object() cannot forge it either', () { + Formbricks.setEmbeddedData({'plan': 'pro'}); + + Formbricks.clearEmbeddedData(Object()); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + }); +} diff --git a/packages/formbricks/test/user/user_test.dart b/packages/formbricks/test/user/user_test.dart index 4179ac6..73392e1 100644 --- a/packages/formbricks/test/user/user_test.dart +++ b/packages/formbricks/test/user/user_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:formbricks/src/common/api_client.dart'; import 'package:formbricks/src/common/config.dart'; import 'package:formbricks/src/common/logger.dart'; +import 'package:formbricks/src/survey/embedded_data.dart'; import 'package:formbricks/src/user/update_queue.dart'; import 'package:formbricks/src/user/user.dart'; import 'package:http/http.dart' as http; @@ -172,4 +173,57 @@ void main() { ); }); }); + + /// The ambient Embedded Data bag survives a survey, so it has to be cleared + /// where identity changes — otherwise one user's context rides onto the next + /// user's responses on a shared device. Deliberately NOT cleared on first + /// identification: a host legitimately pushes context before it knows who the + /// user is. + group('Embedded Data bag on identity change', () { + final store = EmbeddedDataStore.instance; + + setUp(store.clear); + tearDown(store.clear); + + test('switching to a different userId clears the bag', () async { + final config = await _seed(userId: 'old'); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = _noopApi(); + store.set({'plan': 'pro'}); + + await setUserId('new', config: config, queue: queue); + + expect(store.snapshot(), isEmpty); + }); + + test('identifying for the first time keeps the bag', () async { + final config = await _seed(); + final queue = UpdateQueue.instance..configOverride = config; + store.set({'plan': 'pro'}); + + await setUserId('u1', config: config, queue: queue); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + + test('setting the same userId again keeps the bag', () async { + final config = await _seed(userId: 'u1'); + final queue = UpdateQueue.instance..configOverride = config; + store.set({'plan': 'pro'}); + + await setUserId('u1', config: config, queue: queue); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + + test('logout clears the bag', () async { + final config = await _seed(userId: 'u1'); + store.set({'plan': 'pro'}); + + await logout(config: config); + + expect(store.snapshot(), isEmpty); + }); + }); } diff --git a/packages/formbricks/test/widgets/survey_html_test.dart b/packages/formbricks/test/widgets/survey_html_test.dart index bb1fdde..5c53d26 100644 --- a/packages/formbricks/test/widgets/survey_html_test.dart +++ b/packages/formbricks/test/widgets/survey_html_test.dart @@ -20,6 +20,7 @@ SurveyHtmlOptions _opts({ bool branding = true, String languageCode = 'default', String? contactId, + Map hiddenFieldsRecord = const {}, }) => SurveyHtmlOptions( survey: survey ?? _survey(), @@ -32,6 +33,7 @@ SurveyHtmlOptions _opts({ clickOutside: clickOutside, overlay: overlay, contactId: contactId, + hiddenFieldsRecord: hiddenFieldsRecord, ); void main() { @@ -172,4 +174,32 @@ void main() { expect(html, contains('Formbricks WebView Survey')); }); }); + + group('hiddenFieldsRecord', () { + test('rides the render options raw, including keys the survey ignores', () { + // The Embedded Data bag (ENG-1844/2472) uses the options blob that already + // exists — no new bridge message. The SDK does no filtering of its own: + // the renderer owns the allow-list, so an undeclared key must survive the + // trip and be dropped there, not here. + final html = buildSurveyHtml( + _opts( + hiddenFieldsRecord: const { + 'plan': 'pro', + 'notDeclaredBySurvey': 'kept — the renderer decides, not the SDK', + }, + ), + ); + + expect(html, contains('"hiddenFieldsRecord":{')); + expect(html, contains('"plan":"pro"')); + expect(html, contains('"notDeclaredBySurvey"')); + }); + + test('is an empty object when the host set nothing', () { + expect( + buildSurveyHtml(_opts()), + contains('"hiddenFieldsRecord":{}'), + ); + }); + }); } diff --git a/packages/formbricks/test/widgets/survey_webview_test.dart b/packages/formbricks/test/widgets/survey_webview_test.dart index 0359aa6..6691a2f 100644 --- a/packages/formbricks/test/widgets/survey_webview_test.dart +++ b/packages/formbricks/test/widgets/survey_webview_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:formbricks/src/common/config.dart'; import 'package:formbricks/src/common/logger.dart'; +import 'package:formbricks/src/survey/embedded_data.dart'; import 'package:formbricks/src/survey/survey_store.dart'; import 'package:formbricks/src/types/survey.dart'; import 'package:formbricks/src/user/update_queue.dart'; @@ -150,6 +151,57 @@ void main() { Logger.resetInstance(); SurveyStore.resetInstance(); FormbricksConfig.resetInstance(); + EmbeddedDataStore.instance.clear(); + }); + + tearDown(EmbeddedDataStore.instance.clear); + + group('the Embedded Data pipe', () { + // `_present` passing `hiddenFieldsRecord: EmbeddedDataStore.instance + // .snapshot()` is the single line joining the store, the render options and + // the renderer. The store, the options and the identity-clear are each + // tested in isolation elsewhere, so without these two the line could be + // deleted with every other test still green — and its failure mode is + // silent: the survey renders, the values just never arrive. + testWidgets('the display-time snapshot reaches the WebView html', + (tester) async { + await _seedConfig(); + EmbeddedDataStore.instance.set({'plan': 'pro', 'screen': 'checkout'}); + + final host = await _present( + tester, + _survey({'id': 's1', 'languages': []}), + ); + + expect( + host.html, + contains('"hiddenFieldsRecord":{"plan":"pro","screen":"checkout"}'), + ); + }); + + testWidgets('a value set during the delay still reaches the survey', + (tester) async { + // Pins "read at display, not at mount": the bag is empty when the widget + // mounts and only filled while the delay timer runs. + await _seedConfig(); + final survey = + _survey({'id': 's1', 'delay': 2, 'languages': []}); + final host = _StubHost(); + SurveyStore.instance.setSurvey(survey); + await tester.pumpWidget( + MaterialApp( + home: SurveyWebView(survey: survey, webViewHostBuilder: host.build), + ), + ); + await tester.pump(); // _start arms the timer + expect(host.html, isNull); + + EmbeddedDataStore.instance.set({'plan': 'pro'}); + await tester.pump(const Duration(seconds: 2)); // timer fires + await tester.pump(); // route push + + expect(host.html, contains('"hiddenFieldsRecord":{"plan":"pro"}')); + }); }); testWidgets('presents a single-language survey immediately', (tester) async {