@drip-apex/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/_headers +3 -0
- package/dist/bundle-report.json +564 -0
- package/dist/drip.debug.js +2 -0
- package/dist/drip.debug.js.map +1 -0
- package/dist/drip.heatmap.js +2 -0
- package/dist/drip.heatmap.js.map +1 -0
- package/dist/drip.js +2 -0
- package/dist/drip.js.map +1 -0
- package/dist/drip.live.js +2 -0
- package/dist/drip.live.js.map +1 -0
- package/dist/editor.js +480 -0
- package/dist/editor.js.map +7 -0
- package/dist/index.mjs +645 -0
- package/dist/index.mjs.map +7 -0
- package/dist/metafiles/drip.debug.meta.json +973 -0
- package/dist/metafiles/drip.heatmap.meta.json +918 -0
- package/dist/metafiles/drip.live.meta.json +918 -0
- package/dist/metafiles/drip.meta.json +973 -0
- package/dist/metafiles/editor.meta.json +321 -0
- package/dist/metafiles/index.meta.json +1039 -0
- package/dist/metafiles/index.mjs.meta.json +78 -0
- package/dist/react/index.js +16973 -0
- package/dist/react/index.js.map +7 -0
- package/dist/sri.json +27 -0
- package/dist/types/analytics.d.ts +15 -0
- package/dist/types/antiflicker.d.ts +14 -0
- package/dist/types/assignment-store.d.ts +30 -0
- package/dist/types/audit.d.ts +9 -0
- package/dist/types/bucket.d.ts +2 -0
- package/dist/types/bucketing-cleanup.d.ts +5 -0
- package/dist/types/commerce-gift.d.ts +41 -0
- package/dist/types/commerce-price.d.ts +22 -0
- package/dist/types/commerce-shipping.d.ts +28 -0
- package/dist/types/css.d.ts +5 -0
- package/dist/types/cwv.d.ts +10 -0
- package/dist/types/data-layer-normalize.d.ts +1 -0
- package/dist/types/debug.d.ts +23 -0
- package/dist/types/define.d.ts +87 -0
- package/dist/types/devtools.d.ts +32 -0
- package/dist/types/dom-trigger.d.ts +3 -0
- package/dist/types/editor/api.d.ts +37 -0
- package/dist/types/editor/artifacts.d.ts +15 -0
- package/dist/types/editor/command-runtime.d.ts +48 -0
- package/dist/types/editor/controller.d.ts +97 -0
- package/dist/types/editor/device-preview.d.ts +23 -0
- package/dist/types/editor/index.d.ts +9 -0
- package/dist/types/editor/operator.d.ts +39 -0
- package/dist/types/editor/picker.d.ts +23 -0
- package/dist/types/editor/selector.d.ts +2 -0
- package/dist/types/editor/types.d.ts +55 -0
- package/dist/types/editor/view-kit.d.ts +33 -0
- package/dist/types/editor/view.d.ts +5 -0
- package/dist/types/editor-bootstrap.d.ts +37 -0
- package/dist/types/exclusion-groups.d.ts +59 -0
- package/dist/types/exposure-signals.d.ts +7 -0
- package/dist/types/flags.d.ts +65 -0
- package/dist/types/goals.d.ts +16 -0
- package/dist/types/guards.d.ts +39 -0
- package/dist/types/handover.d.ts +10 -0
- package/dist/types/hash.d.ts +19 -0
- package/dist/types/heatmap.d.ts +8 -0
- package/dist/types/hideflicker.d.ts +15 -0
- package/dist/types/hydration.d.ts +11 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/js.d.ts +6 -0
- package/dist/types/module.d.ts +4 -0
- package/dist/types/money.d.ts +14 -0
- package/dist/types/mutate.d.ts +34 -0
- package/dist/types/observer-mute.d.ts +3 -0
- package/dist/types/pii-keys.d.ts +1 -0
- package/dist/types/prerequisites.d.ts +12 -0
- package/dist/types/qa-assistant-state.d.ts +68 -0
- package/dist/types/qa-assistant-view.d.ts +6 -0
- package/dist/types/qa-assistant.d.ts +9 -0
- package/dist/types/qa-toolbar.d.ts +19 -0
- package/dist/types/react/index.d.ts +84 -0
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/router.d.ts +9 -0
- package/dist/types/runtime-tools.d.ts +50 -0
- package/dist/types/runtime.d.ts +22 -0
- package/dist/types/safe-regex.d.ts +6 -0
- package/dist/types/sanitize.d.ts +5 -0
- package/dist/types/semantic.d.ts +7 -0
- package/dist/types/shopify-cart-events.d.ts +7 -0
- package/dist/types/shopify-cart.d.ts +62 -0
- package/dist/types/signals.d.ts +31 -0
- package/dist/types/surface-signals.d.ts +6 -0
- package/dist/types/targeting-diagnostics.d.ts +5 -0
- package/dist/types/targeting.d.ts +50 -0
- package/dist/types/test-fixtures/commerce.d.ts +12 -0
- package/dist/types/track.d.ts +79 -0
- package/dist/types/types.d.ts +859 -0
- package/dist/types/wait.d.ts +7 -0
- package/package.json +74 -0
- package/src/analytics.ts +146 -0
- package/src/antiflicker.test.ts +127 -0
- package/src/antiflicker.ts +147 -0
- package/src/assignment-store.test.ts +171 -0
- package/src/assignment-store.ts +208 -0
- package/src/audit.test.ts +17 -0
- package/src/audit.ts +362 -0
- package/src/bucket.test.ts +65 -0
- package/src/bucket.ts +18 -0
- package/src/bucketing-cleanup.test.ts +30 -0
- package/src/bucketing-cleanup.ts +72 -0
- package/src/bucketing-parity.test.ts +41 -0
- package/src/commerce-analytics-lifecycle.test.ts +1416 -0
- package/src/commerce-config-lifecycle.test.ts +274 -0
- package/src/commerce-epoch-lifecycle.test.ts +62 -0
- package/src/commerce-gift.test.ts +1293 -0
- package/src/commerce-gift.ts +979 -0
- package/src/commerce-offers-display.test.ts +154 -0
- package/src/commerce-price.test.ts +1145 -0
- package/src/commerce-price.ts +941 -0
- package/src/commerce-revocation-lifecycle.test.ts +78 -0
- package/src/commerce-shipping.test.ts +630 -0
- package/src/commerce-shipping.ts +447 -0
- package/src/config-fetch.test.ts +325 -0
- package/src/css.ts +54 -0
- package/src/cwv.test.ts +95 -0
- package/src/cwv.ts +129 -0
- package/src/data-layer-normalize.ts +21 -0
- package/src/debug.test.ts +83 -0
- package/src/debug.ts +138 -0
- package/src/define.test.ts +327 -0
- package/src/define.ts +208 -0
- package/src/devtools.ts +582 -0
- package/src/dom-trigger.test.ts +70 -0
- package/src/dom-trigger.ts +55 -0
- package/src/editor/api.test.ts +154 -0
- package/src/editor/api.ts +185 -0
- package/src/editor/artifacts.test.ts +57 -0
- package/src/editor/artifacts.ts +115 -0
- package/src/editor/command-runtime.test.ts +225 -0
- package/src/editor/command-runtime.ts +572 -0
- package/src/editor/controller.test.ts +371 -0
- package/src/editor/controller.ts +596 -0
- package/src/editor/device-preview.test.ts +60 -0
- package/src/editor/device-preview.ts +120 -0
- package/src/editor/index.test.ts +161 -0
- package/src/editor/index.ts +169 -0
- package/src/editor/operator.test.ts +148 -0
- package/src/editor/operator.ts +256 -0
- package/src/editor/picker.test.ts +58 -0
- package/src/editor/picker.ts +152 -0
- package/src/editor/selector.test.ts +44 -0
- package/src/editor/selector.ts +110 -0
- package/src/editor/types.ts +92 -0
- package/src/editor/view-kit.ts +162 -0
- package/src/editor/view.test.ts +535 -0
- package/src/editor/view.ts +1395 -0
- package/src/editor-bootstrap.test.ts +116 -0
- package/src/editor-bootstrap.ts +213 -0
- package/src/event-schema-contract.test.ts +35 -0
- package/src/exclusion-groups.test.ts +383 -0
- package/src/exclusion-groups.ts +270 -0
- package/src/exposure-signals.test.ts +182 -0
- package/src/exposure-signals.ts +110 -0
- package/src/flags.test.ts +322 -0
- package/src/flags.ts +201 -0
- package/src/goals.test.ts +42 -0
- package/src/goals.ts +200 -0
- package/src/guards.test.ts +249 -0
- package/src/guards.ts +250 -0
- package/src/handover-cache-order.test.ts +32 -0
- package/src/handover.test.ts +349 -0
- package/src/handover.ts +309 -0
- package/src/hash.ts +214 -0
- package/src/heatmap.test.ts +305 -0
- package/src/heatmap.ts +373 -0
- package/src/hideflicker.ts +21 -0
- package/src/hydration.test.ts +252 -0
- package/src/hydration.ts +203 -0
- package/src/index.ts +7513 -0
- package/src/init-empty.test.ts +5572 -0
- package/src/js.test.ts +26 -0
- package/src/js.ts +61 -0
- package/src/module.ts +50 -0
- package/src/money.test.ts +35 -0
- package/src/money.ts +53 -0
- package/src/mutate.test.ts +304 -0
- package/src/mutate.ts +469 -0
- package/src/observer-mute.test.ts +88 -0
- package/src/observer-mute.ts +45 -0
- package/src/page-trigger.test.ts +84 -0
- package/src/personalization.test.ts +68 -0
- package/src/pii-keys.ts +17 -0
- package/src/prerequisites.test.ts +39 -0
- package/src/prerequisites.ts +94 -0
- package/src/qa-assistant-state.test.ts +471 -0
- package/src/qa-assistant-state.ts +505 -0
- package/src/qa-assistant-view.test.ts +185 -0
- package/src/qa-assistant-view.ts +768 -0
- package/src/qa-assistant.ts +40 -0
- package/src/qa-toolbar.ts +444 -0
- package/src/react/index.test.tsx +177 -0
- package/src/react/index.tsx +252 -0
- package/src/reassert-sentinel.test.ts +314 -0
- package/src/reassert-sentinel.ts +191 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +92 -0
- package/src/runtime-tools.test.ts +112 -0
- package/src/runtime-tools.ts +403 -0
- package/src/runtime.test.ts +217 -0
- package/src/runtime.ts +268 -0
- package/src/safe-regex.ts +35 -0
- package/src/sanitize.ts +34 -0
- package/src/semantic.ts +245 -0
- package/src/shopify-cart-events.test.ts +76 -0
- package/src/shopify-cart-events.ts +115 -0
- package/src/shopify-cart.test.ts +2823 -0
- package/src/shopify-cart.ts +1256 -0
- package/src/signals.ts +226 -0
- package/src/surface-signals.test.ts +83 -0
- package/src/surface-signals.ts +165 -0
- package/src/targeting-diagnostics.ts +560 -0
- package/src/targeting.detailed.test.ts +443 -0
- package/src/targeting.test.ts +1054 -0
- package/src/targeting.ts +1024 -0
- package/src/test-fixtures/commerce.ts +65 -0
- package/src/track.test.ts +817 -0
- package/src/track.ts +519 -0
- package/src/types.ts +991 -0
- package/src/wait.test.ts +96 -0
- package/src/wait.ts +238 -0
package/src/js.test.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { loadExternalJs, setJsPolicy } from "./js";
|
|
4
|
+
|
|
5
|
+
describe("external JavaScript policy", () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
document.head.innerHTML = "";
|
|
8
|
+
setJsPolicy("hybrid");
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("blocks external scripts under strict policy and allows them otherwise", async () => {
|
|
13
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
14
|
+
setJsPolicy("strict");
|
|
15
|
+
await loadExternalJs("https://example.com/strict.js", "strict");
|
|
16
|
+
expect(document.querySelector("script")).toBeNull();
|
|
17
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("blocked by strict policy"));
|
|
18
|
+
|
|
19
|
+
setJsPolicy("hybrid");
|
|
20
|
+
const loading = loadExternalJs("https://example.com/legacy.js", "legacy");
|
|
21
|
+
const script = document.querySelector("script") as HTMLScriptElement;
|
|
22
|
+
expect(script.src).toBe("https://example.com/legacy.js");
|
|
23
|
+
script.onload?.(new Event("load"));
|
|
24
|
+
await loading;
|
|
25
|
+
});
|
|
26
|
+
});
|
package/src/js.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { CustomJsPolicy } from "./types";
|
|
2
|
+
|
|
3
|
+
let activePolicy: CustomJsPolicy = "hybrid";
|
|
4
|
+
const EXTERNAL_JS_CLASS = "drip-external-js";
|
|
5
|
+
|
|
6
|
+
export function setJsPolicy(policy: CustomJsPolicy): void {
|
|
7
|
+
activePolicy = policy;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isStrictJsPolicy(): boolean {
|
|
11
|
+
return activePolicy === "strict";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function execJs(code: string, sourceLabel?: string): void {
|
|
15
|
+
if (isStrictJsPolicy()) {
|
|
16
|
+
if (typeof console !== "undefined") {
|
|
17
|
+
console.warn("[drip] Custom JS blocked by strict policy for " + (sourceLabel || "unknown"));
|
|
18
|
+
}
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const suffix = sourceLabel ? "\n//# sourceURL=drip-" + sourceLabel + ".js" : "";
|
|
24
|
+
new Function(code + suffix)();
|
|
25
|
+
} catch (e) {
|
|
26
|
+
if (typeof console !== "undefined") {
|
|
27
|
+
console.error("[drip] JS error in " + (sourceLabel || "unknown") + ":", e);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadExternalJs(url: string, sourceLabel: string): Promise<void> {
|
|
33
|
+
if (activePolicy === "strict") {
|
|
34
|
+
if (typeof console !== "undefined") {
|
|
35
|
+
console.warn("[drip] External JS blocked by strict policy for " + sourceLabel);
|
|
36
|
+
}
|
|
37
|
+
return Promise.resolve();
|
|
38
|
+
}
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const id = "drip-external-js-" + sourceLabel;
|
|
41
|
+
const previous = document.getElementById(id);
|
|
42
|
+
if (previous && previous.getAttribute("src") === url) {
|
|
43
|
+
resolve();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (previous) previous.remove();
|
|
47
|
+
|
|
48
|
+
const script = document.createElement("script");
|
|
49
|
+
script.id = id;
|
|
50
|
+
script.className = EXTERNAL_JS_CLASS;
|
|
51
|
+
script.src = url;
|
|
52
|
+
script.async = false;
|
|
53
|
+
script.onload = () => resolve();
|
|
54
|
+
script.onerror = () => reject(new Error("Failed to load JS artifact: " + url));
|
|
55
|
+
(document.head || document.documentElement).appendChild(script);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function removeAllExternalJs(): void {
|
|
60
|
+
document.querySelectorAll("." + EXTERNAL_JS_CLASS).forEach((el) => el.remove());
|
|
61
|
+
}
|
package/src/module.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Keep npm module exports separate from the browser IIFE entry. The IIFE is
|
|
2
|
+
// embedded into the Worker and exposed through the Drip global, while npm
|
|
3
|
+
// consumers need real ESM bindings from the package root.
|
|
4
|
+
//
|
|
5
|
+
// Deliberately NO star re-export of ./index: evaluating the full runtime
|
|
6
|
+
// entry would bootstrap browser side effects for consumers who only want
|
|
7
|
+
// the chooser APIs. And because the hosted drip.js runtime owns the live
|
|
8
|
+
// gift state, these wrappers DELEGATE to window.drip when it is present —
|
|
9
|
+
// an ESM chooser bundled separately must never operate on a second copy of
|
|
10
|
+
// the module state.
|
|
11
|
+
import {
|
|
12
|
+
getPendingGiftChoices as localGetPendingGiftChoices,
|
|
13
|
+
selectGiftOption as localSelectGiftOption,
|
|
14
|
+
type PendingGiftChoice,
|
|
15
|
+
} from "./commerce-gift";
|
|
16
|
+
|
|
17
|
+
type ChooserRuntime = {
|
|
18
|
+
getPendingGiftChoices?: (experimentId?: string) => PendingGiftChoice[];
|
|
19
|
+
selectGiftOption?: (
|
|
20
|
+
experimentId: string,
|
|
21
|
+
variantGid: string,
|
|
22
|
+
) => Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function activeRuntime(): ChooserRuntime | undefined {
|
|
26
|
+
return typeof window === "undefined"
|
|
27
|
+
? undefined
|
|
28
|
+
: (window as Window & { drip?: ChooserRuntime }).drip;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getPendingGiftChoices(
|
|
32
|
+
experimentId?: string,
|
|
33
|
+
): PendingGiftChoice[] {
|
|
34
|
+
const runtime = activeRuntime();
|
|
35
|
+
return runtime?.getPendingGiftChoices
|
|
36
|
+
? runtime.getPendingGiftChoices(experimentId)
|
|
37
|
+
: localGetPendingGiftChoices(experimentId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function selectGiftOption(
|
|
41
|
+
experimentId: string,
|
|
42
|
+
variantGid: string,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
const runtime = activeRuntime();
|
|
45
|
+
return runtime?.selectGiftOption
|
|
46
|
+
? runtime.selectGiftOption(experimentId, variantGid)
|
|
47
|
+
: localSelectGiftOption(experimentId, variantGid);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type { PendingGiftChoice };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatCentsLike, parseDisplayedCents } from "./money";
|
|
3
|
+
|
|
4
|
+
describe("money display parsing", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
["$1,299.00", 129900, "1,149.95"],
|
|
7
|
+
["€1.299,00", 129900, "1.149,95"],
|
|
8
|
+
["1 299,00 kr", 129900, "1 149,95"],
|
|
9
|
+
["£12.50", 1250, "11.95"],
|
|
10
|
+
["12,50€", 1250, "11,95"],
|
|
11
|
+
["1299", 129900, "1149.95"],
|
|
12
|
+
["€1.299", 129900, "1.149,95"],
|
|
13
|
+
["$1,299", 129900, "1,149.95"],
|
|
14
|
+
])("parses and preserves %s", (text, cents, formatted) => {
|
|
15
|
+
const parsed = parseDisplayedCents(text);
|
|
16
|
+
expect(parsed?.cents).toBe(cents);
|
|
17
|
+
expect(parsed && formatCentsLike(cents === 1250 ? 1195 : 114995, parsed.convention)).toBe(formatted);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("supports zero-decimal currencies", () => {
|
|
21
|
+
const parsed = parseDisplayedCents("¥1299", 0);
|
|
22
|
+
expect(parsed?.cents).toBe(1299);
|
|
23
|
+
expect(parsed && formatCentsLike(1149, parsed.convention)).toBe("1149");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("honors supplied three-decimal minor-unit precision", () => {
|
|
27
|
+
const parsed = parseDisplayedCents("KD 1.234", 3);
|
|
28
|
+
expect(parsed?.cents).toBe(1234);
|
|
29
|
+
expect(parsed && formatCentsLike(1123, parsed.convention)).toBe("1.123");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it.each(["10 and 20", "no digits"])("rejects ambiguous or absent amounts: %s", (text) => {
|
|
33
|
+
expect(parseDisplayedCents(text)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
package/src/money.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type MoneyConvention = {
|
|
2
|
+
decimal: "." | ",";
|
|
3
|
+
group: string;
|
|
4
|
+
decimals: number;
|
|
5
|
+
minorUnits: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type DisplayedMoney = {
|
|
9
|
+
cents: number;
|
|
10
|
+
convention: MoneyConvention;
|
|
11
|
+
start: number;
|
|
12
|
+
end: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function parseDisplayedCents(text: string, minorUnits = 2): DisplayedMoney | null {
|
|
16
|
+
const pattern = /\d(?:[\d.,\u00a0 ]*\d)?/g;
|
|
17
|
+
const match = pattern.exec(text);
|
|
18
|
+
if (!match || pattern.exec(text)) return null;
|
|
19
|
+
const raw = match[0];
|
|
20
|
+
const compact = raw.replace(/[ \u00a0]/g, "");
|
|
21
|
+
const separator = Math.max(compact.lastIndexOf("."), compact.lastIndexOf(","));
|
|
22
|
+
const trailing = separator < 0 ? 0 : compact.length - separator - 1;
|
|
23
|
+
const decimals = trailing && trailing <= minorUnits ? trailing : 0;
|
|
24
|
+
|
|
25
|
+
const visibleDecimal = decimals ? compact[separator] as "." | "," : null;
|
|
26
|
+
const groupMatch = raw.slice(0, visibleDecimal ? raw.lastIndexOf(visibleDecimal) : raw.length).match(/[.,\u00a0 ]/);
|
|
27
|
+
const group = groupMatch?.[0] ?? "";
|
|
28
|
+
// When the display has grouping but no decimals (for example `1.299`),
|
|
29
|
+
// preserve the locale's opposite separator for a treatment that introduces
|
|
30
|
+
// a fractional amount. Reusing the group separator would produce `1.149.95`.
|
|
31
|
+
const decimal = visibleDecimal ?? (group === "." ? "," : ".");
|
|
32
|
+
const majorText = (decimals ? compact.slice(0, separator) : compact).replace(/[.,]/g, "");
|
|
33
|
+
const fraction = decimals ? compact.slice(separator + 1) : "";
|
|
34
|
+
const scale = 10 ** minorUnits;
|
|
35
|
+
const cents = Number(majorText) * scale + Number((fraction + "0".repeat(minorUnits)).slice(0, minorUnits) || 0);
|
|
36
|
+
return {
|
|
37
|
+
cents,
|
|
38
|
+
convention: { decimal, group, decimals, minorUnits },
|
|
39
|
+
start: match.index,
|
|
40
|
+
end: match.index + raw.length,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatCentsLike(cents: number, convention: MoneyConvention): string {
|
|
45
|
+
const scale = 10 ** convention.minorUnits;
|
|
46
|
+
let major = String(Math.floor(cents / scale));
|
|
47
|
+
if (convention.group) major = major.replace(/\B(?=(\d{3})+(?!\d))/g, convention.group);
|
|
48
|
+
const remainder = cents % scale;
|
|
49
|
+
const decimals = convention.decimals || (remainder ? convention.minorUnits : 0);
|
|
50
|
+
return decimals
|
|
51
|
+
? major + convention.decimal + String(remainder).padStart(convention.minorUnits, "0").slice(0, decimals)
|
|
52
|
+
: major;
|
|
53
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { applyTrackedMutation, createMutationApplicationContext, getMutationKey, isInsertAction, isInsertDuplicate, resetAppliedMutations, __test } from "./mutate";
|
|
4
|
+
import { setJsPolicy } from "./js";
|
|
5
|
+
|
|
6
|
+
const { normalizeCssPropertyName } = __test;
|
|
7
|
+
|
|
8
|
+
describe("normalizeCssPropertyName", () => {
|
|
9
|
+
it("converts camelCase to kebab-case (backgroundColor)", () => {
|
|
10
|
+
expect(normalizeCssPropertyName("backgroundColor")).toBe(
|
|
11
|
+
"background-color",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("passes through custom properties (--my-var)", () => {
|
|
16
|
+
expect(normalizeCssPropertyName("--my-var")).toBe("--my-var");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("passes through already kebab-case (background-color)", () => {
|
|
20
|
+
expect(normalizeCssPropertyName("background-color")).toBe(
|
|
21
|
+
"background-color",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("converts msTransform to -ms-transform", () => {
|
|
26
|
+
expect(normalizeCssPropertyName("msTransform")).toBe("-ms-transform");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns empty string for empty input", () => {
|
|
30
|
+
expect(normalizeCssPropertyName("")).toBe("");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("converts WebkitTransform to -webkit-transform", () => {
|
|
34
|
+
// "WebkitTransform" → replace A-Z → "-webkit-transform"
|
|
35
|
+
// Starts with "W" → "-w", so the output starts with "-w"
|
|
36
|
+
// Actually: W→"-w", T→"-t" → "-webkit-transform"
|
|
37
|
+
expect(normalizeCssPropertyName("WebkitTransform")).toBe(
|
|
38
|
+
"-webkit-transform",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("converts fontSize to font-size", () => {
|
|
43
|
+
expect(normalizeCssPropertyName("fontSize")).toBe("font-size");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("converts borderTopWidth to border-top-width", () => {
|
|
47
|
+
expect(normalizeCssPropertyName("borderTopWidth")).toBe(
|
|
48
|
+
"border-top-width",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("getMutationKey", () => {
|
|
54
|
+
it("is deterministic for the same inputs", () => {
|
|
55
|
+
const mutation = { selector: ".hero", action: "text" as const, text: "Hi" };
|
|
56
|
+
const key1 = getMutationKey(mutation, 0);
|
|
57
|
+
const key2 = getMutationKey(mutation, 0);
|
|
58
|
+
expect(key1).toBe(key2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("produces different keys for different actions", () => {
|
|
62
|
+
const m1 = { selector: ".hero", action: "text" as const, text: "Hi" };
|
|
63
|
+
const m2 = { selector: ".hero", action: "html" as const, text: "Hi" };
|
|
64
|
+
expect(getMutationKey(m1, 0)).not.toBe(getMutationKey(m2, 0));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("produces different keys for different selectors", () => {
|
|
68
|
+
const m1 = { selector: ".hero", action: "text" as const, text: "Hi" };
|
|
69
|
+
const m2 = { selector: ".footer", action: "text" as const, text: "Hi" };
|
|
70
|
+
expect(getMutationKey(m1, 0)).not.toBe(getMutationKey(m2, 0));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("produces different keys for different text content", () => {
|
|
74
|
+
const m1 = { selector: ".hero", action: "text" as const, text: "Hello" };
|
|
75
|
+
const m2 = { selector: ".hero", action: "text" as const, text: "World" };
|
|
76
|
+
expect(getMutationKey(m1, 0)).not.toBe(getMutationKey(m2, 0));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("includes index in the key", () => {
|
|
80
|
+
const mutation = { selector: ".hero", action: "text" as const, text: "Hi" };
|
|
81
|
+
const key0 = getMutationKey(mutation, 0);
|
|
82
|
+
const key1 = getMutationKey(mutation, 1);
|
|
83
|
+
expect(key0).not.toBe(key1);
|
|
84
|
+
expect(key0).toContain(":0:");
|
|
85
|
+
expect(key1).toContain(":1:");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("isInsertAction", () => {
|
|
90
|
+
it("returns true for insertBefore", () => {
|
|
91
|
+
expect(isInsertAction("insertBefore")).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns true for insertAfter", () => {
|
|
95
|
+
expect(isInsertAction("insertAfter")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns false for text", () => {
|
|
99
|
+
expect(isInsertAction("text")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns false for html", () => {
|
|
103
|
+
expect(isInsertAction("html")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns false for style", () => {
|
|
107
|
+
expect(isInsertAction("style")).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns false for setStyle", () => {
|
|
111
|
+
expect(isInsertAction("setStyle")).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns false for setText", () => {
|
|
115
|
+
expect(isInsertAction("setText")).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns false for attribute", () => {
|
|
119
|
+
expect(isInsertAction("attribute")).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns false for remove", () => {
|
|
123
|
+
expect(isInsertAction("remove")).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("isInsertDuplicate", () => {
|
|
128
|
+
it("matches inserted markers with quote-heavy keys without using an unsafe selector", () => {
|
|
129
|
+
const key = 'insertAfter:0:#anchor:<div data-suite-insert="applied">Copy</div>';
|
|
130
|
+
const element = {
|
|
131
|
+
parentElement: {
|
|
132
|
+
querySelectorAll: () => [
|
|
133
|
+
{
|
|
134
|
+
getAttribute: (name: string) => (name === "data-drip-inserted" ? key : null),
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
} as unknown as Element;
|
|
139
|
+
|
|
140
|
+
expect(() => isInsertDuplicate(element, key)).not.toThrow();
|
|
141
|
+
expect(isInsertDuplicate(element, key)).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("mutation rollback", () => {
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
resetAppliedMutations();
|
|
148
|
+
setJsPolicy("hybrid");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("blocks dangerous attributes only under strict policy", () => {
|
|
152
|
+
const element = document.createElement("a");
|
|
153
|
+
setJsPolicy("strict");
|
|
154
|
+
applyTrackedMutation({ selector: "a", action: "attribute", attributes: {
|
|
155
|
+
onclick: "alert(1)", href: "javascript:alert(1)", title: "safe",
|
|
156
|
+
} }, element);
|
|
157
|
+
expect(element.getAttribute("onclick")).toBeNull();
|
|
158
|
+
expect(element.getAttribute("href")).toBeNull();
|
|
159
|
+
expect(element.getAttribute("title")).toBe("safe");
|
|
160
|
+
|
|
161
|
+
setJsPolicy("hybrid");
|
|
162
|
+
applyTrackedMutation({ selector: "a", action: "attribute", attributes: {
|
|
163
|
+
onclick: "alert(1)", href: "javascript:alert(1)",
|
|
164
|
+
} }, element);
|
|
165
|
+
expect(element.getAttribute("onclick")).toBe("alert(1)");
|
|
166
|
+
expect(element.getAttribute("href")).toBe("javascript:alert(1)");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it.each([
|
|
170
|
+
["data:application/xhtml+xml,<script>alert(1)</script>", false],
|
|
171
|
+
["data:text/html,<script>alert(1)</script>", false],
|
|
172
|
+
["data:image/svg+xml,<svg onload='alert(1)'></svg>", false],
|
|
173
|
+
[" \tDATA: IMAGE/PNG ;BASE64,iVBORw0KGgo=", true],
|
|
174
|
+
])("applies strict data URL allowlisting to %s", (url, allowed) => {
|
|
175
|
+
const element = document.createElement("iframe");
|
|
176
|
+
setJsPolicy("strict");
|
|
177
|
+
|
|
178
|
+
applyTrackedMutation({
|
|
179
|
+
selector: "iframe",
|
|
180
|
+
action: "attribute",
|
|
181
|
+
attributes: { src: url },
|
|
182
|
+
}, element);
|
|
183
|
+
|
|
184
|
+
expect(element.hasAttribute("src")).toBe(allowed);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it.each(["html", "insertBefore", "insertAfter"] as const)(
|
|
188
|
+
"strips executable attributes from %s mutations under strict policy",
|
|
189
|
+
(action) => {
|
|
190
|
+
document.body.innerHTML = '<div id="target"></div>';
|
|
191
|
+
const target = document.querySelector("#target")!;
|
|
192
|
+
setJsPolicy("strict");
|
|
193
|
+
applyTrackedMutation({
|
|
194
|
+
selector: "#target",
|
|
195
|
+
action,
|
|
196
|
+
html: '<iframe srcdoc="<script>alert(1)</script>"></iframe><a onclick="alert(1)" href="javascript:alert(1)">Safe copy</a>',
|
|
197
|
+
}, target);
|
|
198
|
+
const scope = action === "html" ? target : document.body;
|
|
199
|
+
expect(scope.querySelector("iframe")?.hasAttribute("srcdoc")).toBe(false);
|
|
200
|
+
expect(scope.querySelector("a")?.hasAttribute("onclick")).toBe(false);
|
|
201
|
+
expect(scope.querySelector("a")?.hasAttribute("href")).toBe(false);
|
|
202
|
+
expect(scope.querySelector("a")?.textContent).toBe("Safe copy");
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
it("restores text, attributes, and styles in reverse application order", () => {
|
|
207
|
+
const attributes = new Map<string, string>([["aria-label", "Original"]]);
|
|
208
|
+
const styleValues = new Map<string, string>([["background-color", "red"]]);
|
|
209
|
+
const priorities = new Map<string, string>();
|
|
210
|
+
const element = {
|
|
211
|
+
textContent: "Original copy",
|
|
212
|
+
getAttribute: (name: string) => attributes.get(name) ?? null,
|
|
213
|
+
setAttribute: (name: string, value: string) => { attributes.set(name, value); },
|
|
214
|
+
removeAttribute: (name: string) => { attributes.delete(name); },
|
|
215
|
+
style: {
|
|
216
|
+
getPropertyValue: (name: string) => styleValues.get(name) ?? "",
|
|
217
|
+
getPropertyPriority: (name: string) => priorities.get(name) ?? "",
|
|
218
|
+
setProperty: (name: string, value: string, priority?: string) => {
|
|
219
|
+
styleValues.set(name, value);
|
|
220
|
+
if (priority) priorities.set(name, priority);
|
|
221
|
+
else priorities.delete(name);
|
|
222
|
+
},
|
|
223
|
+
removeProperty: (name: string) => { styleValues.delete(name); },
|
|
224
|
+
},
|
|
225
|
+
} as unknown as Element;
|
|
226
|
+
|
|
227
|
+
applyTrackedMutation({ selector: ".hero", action: "text", text: "Variant copy" }, element);
|
|
228
|
+
applyTrackedMutation({
|
|
229
|
+
selector: ".hero",
|
|
230
|
+
action: "attribute",
|
|
231
|
+
attributes: { "aria-label": "Variant", "data-new": "1" },
|
|
232
|
+
}, element);
|
|
233
|
+
applyTrackedMutation({
|
|
234
|
+
selector: ".hero",
|
|
235
|
+
action: "style",
|
|
236
|
+
styles: { backgroundColor: "blue" },
|
|
237
|
+
}, element);
|
|
238
|
+
|
|
239
|
+
expect((element as unknown as { textContent: string }).textContent).toBe("Variant copy");
|
|
240
|
+
expect(attributes.get("aria-label")).toBe("Variant");
|
|
241
|
+
expect(attributes.get("data-new")).toBe("1");
|
|
242
|
+
expect(styleValues.get("background-color")).toBe("blue");
|
|
243
|
+
|
|
244
|
+
resetAppliedMutations();
|
|
245
|
+
|
|
246
|
+
expect((element as unknown as { textContent: string }).textContent).toBe("Original copy");
|
|
247
|
+
expect(attributes.get("aria-label")).toBe("Original");
|
|
248
|
+
expect(attributes.has("data-new")).toBe(false);
|
|
249
|
+
expect(styleValues.get("background-color")).toBe("red");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("rolls back editor mutations without reverting shopper assignment state", () => {
|
|
253
|
+
const element = {
|
|
254
|
+
textContent: "Control copy",
|
|
255
|
+
} as unknown as Element;
|
|
256
|
+
|
|
257
|
+
applyTrackedMutation(
|
|
258
|
+
{ selector: ".hero", action: "text", text: "Shopper assignment" },
|
|
259
|
+
element,
|
|
260
|
+
);
|
|
261
|
+
const editor = createMutationApplicationContext();
|
|
262
|
+
editor.apply(
|
|
263
|
+
[{ selector: ".hero", action: "text", text: "Editor draft" }],
|
|
264
|
+
{ root: { querySelectorAll: () => [element] } as unknown as Document },
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
expect((element as unknown as { textContent: string }).textContent).toBe("Editor draft");
|
|
268
|
+
expect(editor.snapshotCount).toBe(1);
|
|
269
|
+
editor.rollback();
|
|
270
|
+
expect((element as unknown as { textContent: string }).textContent).toBe("Shopper assignment");
|
|
271
|
+
|
|
272
|
+
resetAppliedMutations();
|
|
273
|
+
expect((element as unknown as { textContent: string }).textContent).toBe("Control copy");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("rolls back every node from a multi-root insertion", () => {
|
|
277
|
+
document.body.innerHTML = `<p id="before">Before</p><div id="anchor">Anchor</div><p id="after">After</p>`;
|
|
278
|
+
const context = createMutationApplicationContext();
|
|
279
|
+
|
|
280
|
+
context.apply([{
|
|
281
|
+
selector: "#anchor",
|
|
282
|
+
action: "insertBefore",
|
|
283
|
+
html: `lead text<span id="first">First</span><strong id="second">Second</strong>tail text`,
|
|
284
|
+
}]);
|
|
285
|
+
|
|
286
|
+
expect(document.body.textContent).toContain("lead textFirstSecondtail text");
|
|
287
|
+
expect(context.snapshotCount).toBe(1);
|
|
288
|
+
context.rollback();
|
|
289
|
+
expect(document.body.innerHTML).toBe(
|
|
290
|
+
`<p id="before">Before</p><div id="anchor">Anchor</div><p id="after">After</p>`,
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("does not snapshot or remove a pre-existing sibling for an empty insertion", () => {
|
|
295
|
+
document.body.innerHTML = `<div id="anchor">Anchor</div><p id="existing">Existing</p>`;
|
|
296
|
+
const context = createMutationApplicationContext();
|
|
297
|
+
|
|
298
|
+
context.apply([{ selector: "#anchor", action: "insertAfter", html: "" }]);
|
|
299
|
+
|
|
300
|
+
expect(context.snapshotCount).toBe(0);
|
|
301
|
+
context.rollback();
|
|
302
|
+
expect(document.getElementById("existing")?.textContent).toBe("Existing");
|
|
303
|
+
});
|
|
304
|
+
});
|