diff --git a/.gitignore b/.gitignore
index eb9452d..19deb67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,3 +50,5 @@ pnpm-lock.yaml
node_modules
.mcp.json
.serena/
+.claude/tasks/
+.worktrees/
diff --git a/docs/superpowers/plans/2026-04-29-create-cross-variant-exposure.md b/docs/superpowers/plans/2026-04-29-create-cross-variant-exposure.md
new file mode 100644
index 0000000..c3a34af
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-29-create-cross-variant-exposure.md
@@ -0,0 +1,475 @@
+# Cross-Variant Exposure Tracking for `create` Changes Implementation Plan [FT-1879]
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**JIRA:** FT-1879 — Cross-variant exposure tracking gap for `create` changes with `trigger_on_view`
+**PR Title Format:** `fix(exposure): cross-variant tracking for create changes (FT-1879)`
+
+**Goal:** Ensure SRM-preserving exposure tracking when an experiment uses `create` changes with `trigger_on_view: true` across variants — peer variants (those that don't create the element) must still expose when the user scrolls to the would-be position.
+
+**Architecture:** Mirror the existing `move`/`delete` pattern: for every unique `(targetSelector, position)` pair across all variants' viewport-triggered `create` changes, drop a 1px invisible positional placeholder via `createContainerPlaceholder`. Dedupe by `target|position` because a created element only exists in the variant that creates it — cross-variant tracking is positional, not selector-based. Malformed `create` changes (missing `targetSelector`) are skipped because `DOMManipulatorLite.applyChange` already returns false for them — no placeholder, no observation, no crash.
+
+**Tech Stack:** TypeScript, Jest, jsdom, jest-mocked `IntersectionObserver`. Helpers: `createTreatmentTracker` (`src/__tests__/sdk-helper.ts`) and the per-suite `triggerIntersection` defined inline at `src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts:104`.
+
+---
+
+## Status of the Fix
+
+The production fix is **already in place** on this branch:
+
+- `src/core/ExposureTracker.ts:61-96` — first-pass loop now collects `create` changes into a `createPositions: Map<"target|position", {targetSelector, position}>`.
+- `src/core/ExposureTracker.ts:190-204` — after the `delete` block, iterates `createPositions` and calls `createContainerPlaceholder(experimentName, '__create_placeholder__', targetSelector, position)` for each unique pair.
+- Malformed `create` (no `targetSelector`) is intentionally skipped inside the new branch — no fallback path.
+- `.gitignore` updated to exclude `.claude/tasks/` and `.worktrees/`.
+
+The remaining work is **test coverage** to lock in the new behavior and protect against regressions. The existing `6F: Create Changes` block at `src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts:3972` covers only immediate triggers (`trigger_on_view: false`). Tasks below extend it.
+
+## File Structure
+
+| File | Responsibility |
+|------|---------------|
+| `src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts` | New `describe` blocks `6F2` – `6F6` covering viewport-triggered create cross-variant scenarios. All additions go inside the existing `6F: Create Changes` block. |
+
+No production code changes are needed — only tests. Existing fix is locked in; new tests verify it.
+
+---
+
+## Task 1: Baseline — viewport-triggered create in a single variant
+
+**Files:**
+- Modify: `src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts` — add `describe('6F2: Create with viewport trigger', ...)` after the existing `6F1` block, before the closing `});` of `6F`.
+
+**Why:** Sanity baseline before the cross-variant cases. Confirms a placeholder is observed and exposure fires when its position enters viewport.
+
+- [ ] **Step 1: Add the test**
+
+Insert immediately after the `6F1` closing `});` (around line 4044, before the `6F` closing `});`):
+
+```typescript
+ describe('6F2: Create with viewport trigger', () => {
+ it('user in v1 - exposure fires when target position enters viewport', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f2_create_viewport',
+ variants: [
+ { variables: {} },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '
New
',
+ targetSelector: '.container',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f2_create_viewport: 1,
+ });
+ document.body.innerHTML = 'Container
';
+
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ expect(treatmentSpy).not.toHaveBeenCalled();
+ expect(document.querySelector('.new')).not.toBeNull();
+
+ const placeholder = document.querySelector(
+ '.container [data-absmartly-placeholder]'
+ ) as Element;
+ expect(placeholder).not.toBeNull();
+
+ await triggerIntersection(placeholder, true);
+
+ expect(treatmentSpy).toHaveBeenCalledWith('test_6f2_create_viewport');
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+```
+
+- [ ] **Step 2: Run only the new test**
+
+```bash
+npx jest --testPathPattern="crossVariantExposure" -t "6F2"
+```
+
+Expected: 1 passed.
+
+---
+
+## Task 2: Peer variant gets placeholder-based exposure
+
+**Files:**
+- Modify: `src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts` — add `describe('6F3: Cross-variant create — peer variant', ...)` after `6F2`.
+
+**Why:** This is the core SRM case the fix addresses. User in control (no create) must still trigger exposure when the position where v1 would have created the element enters viewport.
+
+- [ ] **Step 1: Add the test**
+
+Insert after the `6F2` closing `});`:
+
+```typescript
+ describe('6F3: Cross-variant create - peer variant placeholder exposure', () => {
+ it('user in v0 (no create) - exposure fires via placeholder at v1 target position', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f3_peer_variant',
+ variants: [
+ { variables: { __dom_changes: [] } },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: 'New
',
+ targetSelector: '.container',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f3_peer_variant: 0,
+ });
+ document.body.innerHTML = 'Container
';
+
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ // v0 user: nothing was created
+ expect(document.querySelector('.new')).toBeNull();
+
+ // ...but a placeholder MUST exist at v1's target position
+ const placeholder = document.querySelector(
+ '.container [data-absmartly-placeholder]'
+ ) as Element;
+ expect(placeholder).not.toBeNull();
+ expect(placeholder.getAttribute('data-absmartly-experiment')).toBe(
+ 'test_6f3_peer_variant'
+ );
+
+ // No exposure until placeholder is visible
+ expect(treatmentSpy).not.toHaveBeenCalled();
+
+ await triggerIntersection(placeholder, true);
+
+ expect(treatmentSpy).toHaveBeenCalledWith('test_6f3_peer_variant');
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+```
+
+- [ ] **Step 2: Run the new test**
+
+```bash
+npx jest --testPathPattern="crossVariantExposure" -t "6F3"
+```
+
+Expected: 1 passed.
+
+---
+
+## Task 3: Multiple variants creating at different positions
+
+**Files:**
+- Modify: `src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts` — add `describe('6F4: Multi-variant create at different positions', ...)` after `6F3`.
+
+**Why:** Confirms placeholders are dropped at every distinct position and that triggering ANY placeholder fires exposure. Three-variant case to flush out off-by-one issues in the dedup map.
+
+- [ ] **Step 1: Add the test**
+
+```typescript
+ describe('6F4: Multi-variant create at different positions', () => {
+ it('user in v0 - placeholders at every other variant position; any one fires exposure', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f4_multi_position',
+ variants: [
+ { variables: { __dom_changes: [] } },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ targetSelector: '.header',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ targetSelector: '.footer',
+ position: 'firstChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f4_multi_position: 0,
+ });
+ document.body.innerHTML =
+ '';
+
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ const headerPlaceholder = document.querySelector(
+ '.header [data-absmartly-placeholder]'
+ ) as Element;
+ const footerPlaceholder = document.querySelector(
+ '.footer [data-absmartly-placeholder]'
+ ) as Element;
+
+ expect(headerPlaceholder).not.toBeNull();
+ expect(footerPlaceholder).not.toBeNull();
+ expect(treatmentSpy).not.toHaveBeenCalled();
+
+ await triggerIntersection(footerPlaceholder, true);
+
+ expect(treatmentSpy).toHaveBeenCalledWith('test_6f4_multi_position');
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+```
+
+- [ ] **Step 2: Run the new test**
+
+```bash
+npx jest --testPathPattern="crossVariantExposure" -t "6F4"
+```
+
+Expected: 1 passed.
+
+---
+
+## Task 4: Same-position dedup across variants
+
+**Files:**
+- Modify: `src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts` — add `describe('6F5: Same-position create dedup', ...)` after `6F4`.
+
+**Why:** Two variants creating different elements at the same `(targetSelector, position)` must produce exactly one placeholder. The `createPositions` Map is keyed by `target|position`, so this verifies the dedup contract.
+
+- [ ] **Step 1: Add the test**
+
+```typescript
+ describe('6F5: Same-position create dedup', () => {
+ it('user in v0 - exactly one placeholder when two variants create at same target/position', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f5_dedup',
+ variants: [
+ { variables: { __dom_changes: [] } },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ targetSelector: '.container',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ targetSelector: '.container',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f5_dedup: 0,
+ });
+ document.body.innerHTML = 'C
';
+
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ const placeholders = document.querySelectorAll(
+ '.container [data-absmartly-placeholder]'
+ );
+ expect(placeholders.length).toBe(1);
+
+ await triggerIntersection(placeholders[0], true);
+
+ expect(treatmentSpy).toHaveBeenCalledWith('test_6f5_dedup');
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+```
+
+- [ ] **Step 2: Run the new test**
+
+```bash
+npx jest --testPathPattern="crossVariantExposure" -t "6F5"
+```
+
+Expected: 1 passed.
+
+---
+
+## Task 5: Malformed create (no `targetSelector`) is a no-op
+
+**Files:**
+- Modify: `src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts` — add `describe('6F6: Malformed create — no targetSelector', ...)` after `6F5`.
+
+**Why:** Confirms the explicit skip in the new branch. A `create` without `targetSelector` cannot be applied by `DOMManipulatorLite` (returns false at `DOMManipulatorLite.ts:190`). The tracker must not crash, must not create a placeholder, and (when no other tracking exists) must not fire exposure.
+
+- [ ] **Step 1: Add the test**
+
+```typescript
+ describe('6F6: Malformed create - no targetSelector', () => {
+ it('user in v0 - no placeholder, no exposure, no crash', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f6_malformed',
+ variants: [
+ { variables: { __dom_changes: [] } },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ // targetSelector intentionally omitted
+ trigger_on_view: true,
+ } as any,
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f6_malformed: 0,
+ });
+ document.body.innerHTML = 'C
';
+
+ // Setup must not throw
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ // No placeholder anywhere for this experiment
+ const placeholder = document.querySelector(
+ '[data-absmartly-experiment="test_6f6_malformed"]'
+ );
+ expect(placeholder).toBeNull();
+
+ // The element was never created either
+ expect(document.querySelector('.orphan')).toBeNull();
+
+ // No exposure - nothing observable means nothing to trigger
+ expect(treatmentSpy).not.toHaveBeenCalled();
+ });
+ });
+```
+
+- [ ] **Step 2: Run the new test**
+
+```bash
+npx jest --testPathPattern="crossVariantExposure" -t "6F6"
+```
+
+Expected: 1 passed.
+
+---
+
+## Task 6: Full suite verification + commit
+
+**Files:**
+- All staged.
+
+- [ ] **Step 1: Run full plugin test suite**
+
+```bash
+npx jest --testPathPattern="DOMChangesPluginLite"
+```
+
+Expected: 7 passed test suites, ≥ 259 passed (254 baseline + 5 new), 1 skipped.
+
+- [ ] **Step 2: Run lint**
+
+```bash
+npm run lint 2>&1 | tail -20
+```
+
+Expected: no errors. (Pre-commit prettier hook will reformat on commit anyway.)
+
+- [ ] **Step 3: Stage changes**
+
+```bash
+git add .gitignore src/core/ExposureTracker.ts src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts docs/superpowers/plans/2026-04-29-create-cross-variant-exposure.md
+git status
+```
+
+Expected status: only the four files above modified/added.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git commit -m "$(cat <<'EOF'
+fix(exposure): cross-variant tracking for create changes (FT-1879)
+
+create changes with trigger_on_view previously fell through the generic
+selector-tracking branch in ExposureTracker, so peer variants without the
+create had nothing observable and never fired exposure when the would-be
+position entered viewport. Mirror the move/delete pattern: collect every
+unique (targetSelector, position) pair across all variants' viewport-
+triggered create changes and drop an invisible positional placeholder at
+each. Malformed creates (no targetSelector) are skipped explicitly --
+the manipulator can't apply them either.
+
+Also gitignore .claude/tasks/ and .worktrees/.
+EOF
+)"
+```
+
+- [ ] **Step 5: Push**
+
+```bash
+git push -u origin fix/FT-1879/create-cross-variant-exposure
+```
+
+Report the branch URL back to the user. PR creation is left to the user (or a follow-up `/create-pr` invocation).
+
+---
+
+## Self-Review Notes
+
+- **Spec coverage:** Acceptance criteria from FT-1879 — (1) placeholder per unique position ✓ (Tasks 2–5 verify); (2) tests across the four scenarios ✓ (Tasks 2/3/4/5); (3) existing suites pass ✓ (Task 6 step 1).
+- **Placeholders:** none — every test step contains complete code and exact run commands.
+- **Type consistency:** `data-absmartly-placeholder` attribute, `data-absmartly-experiment` attribute, and the `__create_placeholder__` originalSelector sentinel are all set in `ExposureTracker.createContainerPlaceholder` (`src/core/ExposureTracker.ts:283-286`). Tests query by attribute, not by value, so the sentinel can change without breaking tests.
diff --git a/src/core/ExposureTracker.ts b/src/core/ExposureTracker.ts
index 87c9a3c..f79f95a 100644
--- a/src/core/ExposureTracker.ts
+++ b/src/core/ExposureTracker.ts
@@ -58,10 +58,15 @@ export class ExposureTracker {
const viewportSelectors = new Set();
const moveParentSelectors = new Set(); // Parent containers for move changes
- // First pass: collect all move and delete changes across all variants
+ // First pass: collect all move, delete, and create changes across all variants
// We need to track ALL possible positions where elements could be
const moveElements = new Map>(); // selector -> Set of target parent positions
const deleteElements = new Set(); // selectors for delete changes
+ // For create: cross-variant exposure is positional (the created element only
+ // exists in its own variant). Nested map avoids any composite-key collision —
+ // CSS selectors can legally contain characters like '|' or ':', so we key
+ // structurally instead of stringifying the pair.
+ const createPositions = new Map>(); // targetSelector -> Set
for (const variantChanges of allVariantsChanges) {
for (const change of variantChanges) {
@@ -78,8 +83,26 @@ export class ExposureTracker {
} else if (change.type === 'delete') {
// Track delete changes - need special handling for placeholders
deleteElements.add(change.selector);
+ } else if (change.type === 'create') {
+ // create requires targetSelector to have a DOM position; without it
+ // the manipulator can't apply the change at all (DOMManipulatorLite
+ // returns false), so there's nothing to track.
+ if (change.targetSelector) {
+ const position = change.position || 'lastChild';
+ let positions = createPositions.get(change.targetSelector);
+ if (!positions) {
+ positions = new Set();
+ createPositions.set(change.targetSelector, positions);
+ }
+ positions.add(position);
+ } else if (this.debug) {
+ logDebug(
+ `[EXPOSURE] [${experimentName}] Skipping create change with no targetSelector — manipulator can't apply it either`,
+ { change }
+ );
+ }
} else {
- // For other non-move, non-delete changes, track the selector directly
+ // For other change types, track the selector directly
viewportSelectors.add(change.selector);
}
}
@@ -178,6 +201,37 @@ export class ExposureTracker {
}
}
+ // For cross-variant create tracking: drop an invisible placeholder at every
+ // (targetSelector, position) where any variant creates an element with
+ // trigger_on_view. This guarantees exposure fires when the user scrolls to
+ // the position regardless of whether their variant actually created the
+ // element. The user's variant that does create the element gets the real
+ // element in DOM at the same position (applyChange runs before this), and
+ // the 1px invisible placeholder coexists harmlessly.
+ //
+ // Fallback: if the targetSelector isn't in the DOM at registration time
+ // (SPA late-mount), createContainerPlaceholder returns false. Track the
+ // targetSelector itself so the existing MutationObserver picks up exposure
+ // when the target eventually appears.
+ for (const [targetSelector, positions] of createPositions) {
+ let anyInserted = false;
+ for (const position of positions) {
+ if (
+ this.createContainerPlaceholder(
+ experimentName,
+ '__create_placeholder__',
+ targetSelector,
+ position
+ )
+ ) {
+ anyInserted = true;
+ }
+ }
+ if (!anyInserted) {
+ viewportSelectors.add(targetSelector);
+ }
+ }
+
// Trigger flags are now passed in from DOMChangesPluginLite after URL filtering
// This ensures only variants matching the current URL determine trigger behavior
@@ -248,21 +302,23 @@ export class ExposureTracker {
/**
* Create a container-based placeholder at the hypothetical position
* Uses inline-block with minimal dimensions to be observable but not affect layout
+ * Returns true when a placeholder was inserted (or already exists for this key);
+ * false when the target element isn't in the DOM yet, so callers can fall back.
*/
private createContainerPlaceholder(
experimentName: string,
originalSelector: string,
targetSelector: string,
position: string = 'lastChild'
- ): void {
+ ): boolean {
const targetElement = document.querySelector(targetSelector);
- if (!targetElement) return;
+ if (!targetElement) return false;
const placeholderKey = `${experimentName}-${originalSelector}-${targetSelector}-${position}`;
// Check if placeholder already exists
if (this.placeholders.has(placeholderKey)) {
- return;
+ return true;
}
// Create minimal placeholder using inline styles
@@ -313,6 +369,8 @@ export class ExposureTracker {
`[ABsmartly] Created placeholder for ${originalSelector} at ${targetSelector} (${position})`
);
}
+
+ return true;
}
/**
diff --git a/src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts b/src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts
index 34187be..0c7e0f3 100644
--- a/src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts
+++ b/src/core/__tests__/DOMChangesPluginLite.crossVariantExposure.test.ts
@@ -4041,6 +4041,479 @@ describe('DOMChangesPluginLite - Comprehensive Cross-Variant Exposure Tracking',
expect(document.querySelector('.new')).not.toBeNull(); // Created in v1
});
});
+
+ describe('6F2: Create with viewport trigger', () => {
+ it('user in v1 - exposure fires when target position enters viewport', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f2_create_viewport',
+ variants: [
+ { variables: {} },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: 'New
',
+ targetSelector: '.container',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f2_create_viewport: 1,
+ });
+ document.body.innerHTML = 'Container
';
+
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ expect(treatmentSpy).not.toHaveBeenCalled();
+ expect(document.querySelector('.new')).not.toBeNull();
+
+ const placeholder = document.querySelector(
+ '.container [data-absmartly-placeholder]'
+ ) as Element;
+ expect(placeholder).not.toBeNull();
+
+ await triggerIntersection(placeholder, true);
+
+ expect(treatmentSpy).toHaveBeenCalledWith('test_6f2_create_viewport');
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('6F3: Cross-variant create - peer variant placeholder exposure', () => {
+ it('user in v0 (no create) - exposure fires via placeholder at v1 target position', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f3_peer_variant',
+ variants: [
+ { variables: { __dom_changes: [] } },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: 'New
',
+ targetSelector: '.container',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f3_peer_variant: 0,
+ });
+ document.body.innerHTML = 'Container
';
+
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ expect(document.querySelector('.new')).toBeNull();
+
+ const placeholder = document.querySelector(
+ '.container [data-absmartly-placeholder]'
+ ) as Element;
+ expect(placeholder).not.toBeNull();
+ expect(placeholder.getAttribute('data-absmartly-experiment')).toBe(
+ 'test_6f3_peer_variant'
+ );
+
+ expect(treatmentSpy).not.toHaveBeenCalled();
+
+ await triggerIntersection(placeholder, true);
+
+ expect(treatmentSpy).toHaveBeenCalledWith('test_6f3_peer_variant');
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+
+ // Cleanup contract: after exposure fires, placeholder is removed from
+ // the DOM, and a second intersection does not re-trigger treatment.
+ expect(document.querySelector('.container [data-absmartly-placeholder]')).toBeNull();
+ await triggerIntersection(placeholder, true);
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('6F4: Multi-variant create at different positions', () => {
+ it('user in v0 - placeholders at every other variant position; any one fires exposure', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f4_multi_position',
+ variants: [
+ { variables: { __dom_changes: [] } },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ targetSelector: '.header',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ targetSelector: '.footer',
+ position: 'firstChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f4_multi_position: 0,
+ });
+ document.body.innerHTML = '';
+
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ const headerPlaceholder = document.querySelector(
+ '.header [data-absmartly-placeholder]'
+ ) as Element;
+ const footerPlaceholder = document.querySelector(
+ '.footer [data-absmartly-placeholder]'
+ ) as Element;
+
+ expect(headerPlaceholder).not.toBeNull();
+ expect(footerPlaceholder).not.toBeNull();
+ expect(treatmentSpy).not.toHaveBeenCalled();
+
+ await triggerIntersection(footerPlaceholder, true);
+
+ expect(treatmentSpy).toHaveBeenCalledWith('test_6f4_multi_position');
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('user in v0 - intersecting the OTHER placeholder also fires exposure', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f4_multi_position_b',
+ variants: [
+ { variables: { __dom_changes: [] } },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ targetSelector: '.header',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ targetSelector: '.footer',
+ position: 'firstChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f4_multi_position_b: 0,
+ });
+ document.body.innerHTML = '';
+
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ const headerPlaceholder = document.querySelector(
+ '.header [data-absmartly-placeholder]'
+ ) as Element;
+ expect(headerPlaceholder).not.toBeNull();
+
+ await triggerIntersection(headerPlaceholder, true);
+
+ expect(treatmentSpy).toHaveBeenCalledWith('test_6f4_multi_position_b');
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('6F5: Same-position create dedup', () => {
+ it('user in v0 - exactly one placeholder when two variants create at same target/position', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f5_dedup',
+ variants: [
+ { variables: { __dom_changes: [] } },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ targetSelector: '.container',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ targetSelector: '.container',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f5_dedup: 0,
+ });
+ document.body.innerHTML = 'C
';
+
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ const placeholders = document.querySelectorAll('.container [data-absmartly-placeholder]');
+ expect(placeholders.length).toBe(1);
+
+ await triggerIntersection(placeholders[0], true);
+
+ expect(treatmentSpy).toHaveBeenCalledWith('test_6f5_dedup');
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('6F6: Malformed create - no targetSelector', () => {
+ it('user in v0 - no placeholder, no exposure, no crash', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f6_malformed',
+ variants: [
+ { variables: { __dom_changes: [] } },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ trigger_on_view: true,
+ } as any,
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f6_malformed: 0,
+ });
+ document.body.innerHTML = 'C
';
+
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ const placeholder = document.querySelector(
+ '[data-absmartly-experiment="test_6f6_malformed"]'
+ );
+ expect(placeholder).toBeNull();
+
+ expect(document.querySelector('.orphan')).toBeNull();
+
+ expect(treatmentSpy).not.toHaveBeenCalled();
+ });
+
+ it('does not poison registration of well-formed sibling changes in the same variant', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f6_malformed_mixed',
+ variants: [
+ { variables: { __dom_changes: [] } },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ trigger_on_view: true,
+ } as any,
+ {
+ selector: '.text-target',
+ type: 'text',
+ value: 'Changed',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f6_malformed_mixed: 0,
+ });
+ document.body.innerHTML =
+ 'C
T
';
+
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ // Malformed create produced no placeholder, but the well-formed sibling
+ // change is still tracked.
+ expect(treatmentSpy).not.toHaveBeenCalled();
+ const textTarget = document.querySelector('.text-target')!;
+ await triggerIntersection(textTarget, true);
+
+ expect(treatmentSpy).toHaveBeenCalledWith('test_6f6_malformed_mixed');
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('6F7: Cross-variant create - delayed-mount targetSelector', () => {
+ it('user in v0 - exposure fires once targetSelector is appended later (SPA late-mount)', async () => {
+ const experiment: ExperimentData = {
+ name: 'test_6f7_delayed_target',
+ variants: [
+ { variables: { __dom_changes: [] } },
+ {
+ variables: {
+ __dom_changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ targetSelector: '.late-container',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f7_delayed_target: 0,
+ });
+ // .late-container is NOT in the DOM at registration time.
+ document.body.innerHTML = 'Other
';
+
+ plugin = new DOMChangesPluginLite({ context: mockContext, autoApply: true, spa: false });
+ await plugin.ready();
+
+ // Placeholder couldn't be inserted (target absent); fallback registers
+ // the targetSelector itself with the MutationObserver.
+ expect(document.querySelector('[data-absmartly-placeholder]')).toBeNull();
+ expect(treatmentSpy).not.toHaveBeenCalled();
+
+ // Target appears later (e.g. SPA navigation / hydration).
+ const lateContainer = document.createElement('div');
+ lateContainer.className = 'late-container';
+ document.body.appendChild(lateContainer);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(treatmentSpy).not.toHaveBeenCalled();
+
+ await triggerIntersection(lateContainer, true);
+
+ expect(treatmentSpy).toHaveBeenCalledWith('test_6f7_delayed_target');
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('6F8: Cross-variant create with URL filter', () => {
+ it('user in v0 - exposure fires when URL matches v1 filter (variant has create + filter)', async () => {
+ const currentUrl = 'https://example.com/products';
+ Object.defineProperty(window, 'location', {
+ value: { href: currentUrl },
+ writable: true,
+ });
+
+ const experiment: ExperimentData = {
+ name: 'test_6f8_url_filter',
+ variants: [
+ { variables: { __dom_changes: [] } },
+ {
+ variables: {
+ __dom_changes: {
+ urlFilter: { include: ['/products'] },
+ changes: [
+ {
+ selector: '',
+ type: 'create',
+ element: '',
+ targetSelector: '.container',
+ position: 'lastChild',
+ trigger_on_view: true,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ };
+
+ for (const userVariant of [0, 1]) {
+ const { mockContext, treatmentSpy } = createTreatmentTracker([experiment], {
+ test_6f8_url_filter: userVariant,
+ });
+ document.body.innerHTML = 'C
';
+
+ plugin = new DOMChangesPluginLite({
+ context: mockContext,
+ autoApply: true,
+ spa: false,
+ });
+ await plugin.ready();
+
+ const placeholder = document.querySelector(
+ '.container [data-absmartly-placeholder]'
+ ) as Element;
+ expect(placeholder).not.toBeNull();
+ expect(treatmentSpy).not.toHaveBeenCalled();
+
+ await triggerIntersection(placeholder, true);
+
+ expect(treatmentSpy).toHaveBeenCalledWith('test_6f8_url_filter');
+ expect(treatmentSpy).toHaveBeenCalledTimes(1);
+
+ plugin.destroy();
+ }
+ });
+ });
});
describe('6G: Edge Cases', () => {