Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions docs/site/scripts/shared-tracking-client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function fixture(options = {}) {
createElement: tag => {
assert.equal(tag, 'iframe');
const frameListeners = new Map();
const attributes = new Map();
frame = {
contentWindow: { postMessage: (data, origin) => {
if (remainingSendFailures > 0) {
Expand All @@ -57,7 +58,8 @@ function fixture(options = {}) {
}
sent.push({ data, origin });
} },
setAttribute() {},
setAttribute: (name, value) => attributes.set(name, value),
getAttribute: name => attributes.get(name) ?? null,
addEventListener: (type, listener) => frameListeners.set(type, listener),
fireLoad: () => frameListeners.get('load')?.(),
fireError: () => frameListeners.get('error')?.(),
Expand Down Expand Up @@ -108,20 +110,88 @@ async function nextRequest(run) {
return { promise };
}

test('reads shared consent through one fixed iframe without a parent tracking script', async () => {
test('reads shared consent through one fixed iframe without a parent tracking script', async (t) => {
const f = fixture();
t.after(() => f.client.close());
const { promise } = await nextRequest(() => f.client.read());
void promise.catch(() => {});
assert.equal(f.frame.src, bridgeUrl);
assert.equal(f.frame.hidden, true);
assert.equal(f.frame.referrerPolicy, 'no-referrer');
assert.equal(f.sent[0].origin, 'https://registrystack.org');
assert.equal(f.sent[0].data.type, 'read');
assert.equal(f.frame.getAttribute('data-tracking-ready'), 'load');
f.reply(unknown);
assert.deepEqual(await promise, unknown);
assert.equal(f.values.size, 0);
f.client.close();
});

test('broker readiness permits a consent read before the iframe load event', async (t) => {
const f = fixture({ autoLoad: false });
t.after(() => f.client.close());
const { promise } = await nextRequest(() => f.client.read());
void promise.catch(() => {});
assert.equal(f.sent.length, 0);

f.message({ protocol: envelope.protocol, version: envelope.version, type: 'ready' });
await new Promise(resolve => setImmediate(resolve));
assert.equal(f.sent.length, 1);
assert.equal(f.sent[0].data.type, 'read');
assert.equal(f.frame.getAttribute('data-tracking-ready'), 'broker');
assert.deepEqual(f.client.state, unknown);
f.reply(rejected);
assert.deepEqual(await promise, rejected);

f.frame.fireLoad();
await new Promise(resolve => setImmediate(resolve));
assert.equal(f.sent.length, 1);
assert.equal(f.failures.length, 0);
});

test('readiness requires the exact envelope from the current bridge origin and frame', async (t) => {
const f = fixture({ autoLoad: false });
t.after(() => f.client.close());
const { promise } = await nextRequest(() => f.client.read());
void promise.catch(() => {});
const ready = { protocol: envelope.protocol, version: envelope.version, type: 'ready' };
f.message(ready, { origin: 'https://attacker.example' });
f.message(ready, { source: {} });
f.message({ ...ready, state: rejected });
f.message({ ...ready, version: 1 });
await new Promise(resolve => setImmediate(resolve));
assert.equal(f.sent.length, 0);
assert.deepEqual(f.client.state, unknown);

f.message(ready);
await new Promise(resolve => setImmediate(resolve));
assert.equal(f.sent.length, 1);
f.reply(rejected);
assert.deepEqual(await promise, rejected);
});

test('readiness from a removed failed frame cannot unlock its replacement', async (t) => {
const f = fixture({ autoLoad: false });
t.after(() => f.client.close());
const { promise } = await nextRequest(() => f.client.read());
void promise.catch(() => {});
const oldFrame = f.frame;
oldFrame.fireError();
await new Promise(resolve => setImmediate(resolve));
assert.equal(f.frames.length, 2);
const ready = { protocol: envelope.protocol, version: envelope.version, type: 'ready' };
f.message(ready, { source: oldFrame.contentWindow });
await new Promise(resolve => setImmediate(resolve));
assert.equal(f.sent.length, 0);

f.message(ready);
await new Promise(resolve => setImmediate(resolve));
assert.equal(f.sent.length, 1);
f.reply(rejected);
assert.deepEqual(await promise, rejected);
assert.equal(f.failures.length, 0);
});

test('concurrent startup and resume reads share one request and confirm an unchanged remote state', async (t) => {
const f = fixture();
t.after(() => f.client.close());
Expand Down
17 changes: 15 additions & 2 deletions docs/site/src/lib/shared-tracking-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
SHARED_TRACKING_BRIDGE_URL,
createTrackingRequest,
parseTrackingChanged,
parseTrackingReady,
parseTrackingResponse,
} from './tracking-protocol.mjs';

Expand Down Expand Up @@ -89,6 +90,7 @@ export function createSharedTrackingClient(options = {}) {
let frame;
let frameReady;
let frameReadyCancel;
let frameReadySignal;
let closed = false;
let suspended = false;
let localRefusal = false;
Expand Down Expand Up @@ -194,6 +196,10 @@ export function createSharedTrackingClient(options = {}) {

const receive = event => {
if (closed || event.origin !== bridge.origin || event.source !== frame?.contentWindow) return;
if (parseTrackingReady(event.data)) {
frameReadySignal?.('broker');
return;
}
const changed = parseTrackingChanged(event.data);
if (changed) {
update(changed.state);
Expand Down Expand Up @@ -255,12 +261,13 @@ export function createSharedTrackingClient(options = {}) {
if (frame === candidate) frame = undefined;
if (frameReady === ready) frameReady = undefined;
if (frameReadyCancel === reset) frameReadyCancel = undefined;
if (frameReadySignal === markReady) frameReadySignal = undefined;
candidate.remove();
rejectReady(trackingError(code));
};
const timer = setTimeout(() => reset('frame-load-timeout'), frameLoadTimeoutMs);
frameReadyCancel = reset;
candidate.addEventListener('load', () => {
const markReady = via => {
if (settled) return;
if (closed || frame !== candidate || frameReady !== ready) {
reset();
Expand All @@ -269,8 +276,14 @@ export function createSharedTrackingClient(options = {}) {
settled = true;
clearTimeout(timer);
if (frameReadyCancel === reset) frameReadyCancel = undefined;
if (frameReadySignal === markReady) frameReadySignal = undefined;
candidate.setAttribute('data-tracking-ready', via);
resolveReady(candidate);
}, { once: true });
};
frameReadySignal = markReady;
// The broker can be usable before unrelated iframe resources finish loading.
// Retain load for cached bridges that do not emit application readiness.
candidate.addEventListener('load', () => markReady('load'), { once: true });
candidate.addEventListener('error', () => reset('frame-load-failed'), { once: true });
try {
currentDocument.body.append(candidate);
Expand Down
7 changes: 7 additions & 0 deletions docs/site/src/lib/tracking-protocol.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,10 @@ export function parseTrackingChanged(value) {
) return null;
return { ...value, state: { ...value.state } };
}

export function parseTrackingReady(value) {
if (!exactKeys(value, ['protocol', 'version', 'type']) ||
value.protocol !== TRACKING_PROTOCOL ||
value.version !== TRACKING_PROTOCOL_VERSION || value.type !== 'ready') return null;
return { ...value };
}
Loading