console fill apple-app-privacy: set up each data type from the real page ('Set Up <type>' is a plain text, clicked by its exact text in the Data Types section), fill its dialog step by step, dump and pause on an unknown step; '?' and Publish never clicked
This commit is contained in:
parent
3aedf9e137
commit
e0bef82b06
7 changed files with 500 additions and 28 deletions
|
|
@ -11,7 +11,7 @@ import { fillSession } from '../cli/console/fill';
|
|||
import type { ConsoleBrowser } from '../cli/console/session';
|
||||
import { APPLE_DATA_TYPE_IDS, type AppleDataTypeId, type AppleTypeAnswer } from '../cli/lib/app-privacy';
|
||||
import type { ShipConfig } from '../cli/lib/config';
|
||||
import { type FlowWhere, flowAllowed, flowClass, FORM_BUTTONS, labelNames, TYPE_ALIASES } from '../cli/lib/console-forms';
|
||||
import { type FlowWhere, flowAllowed, flowClass, FORM_BUTTONS, labelNames, setUpAllowed, setUpTexts, TYPE_ALIASES } from '../cli/lib/console-forms';
|
||||
import { APPLE_PRIVACY_TYPES } from '../cli/lib/app-privacy';
|
||||
import { AFTER_ALL_MS, AFTER_EACH_MS, launchTestBrowser, logLines, testScope } from './helpers/browser';
|
||||
|
||||
|
|
@ -104,6 +104,15 @@ describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
|
|||
for (const n of ['Publish', 'Yayınla', 'Submit for Review']) expect(flowAllowed('apple-app-privacy', n, 'button', at({ inDialog: true, inDataTypes: true }))).toBe(false);
|
||||
expect(flowAllowed('apple-app-privacy', 'Get Started', 'checkbox', at())).toBe(false);
|
||||
});
|
||||
test('the Set Up text gate: exactly "Set Up <a type storebound answers>" in the Data Types section, outside dialogs', () => {
|
||||
const w = at({ inDataTypes: true, dataTypes: ['Coarse Location', 'Purchase History', 'Purchases'] });
|
||||
for (const t of ['Set Up Coarse Location', 'set up coarse location', 'Set Up Purchase History', 'Set Up Purchases']) expect(setUpAllowed(t, w)).toBe(true);
|
||||
for (const t of ['?', 'More information', 'Set Up Device ID', 'Set Up Coarse Location now', 'Publish', 'Coarse Location', 'Edit']) expect(setUpAllowed(t, w)).toBe(false);
|
||||
expect(setUpAllowed('Set Up Coarse Location', { ...w, inDataTypes: false })).toBe(false);
|
||||
expect(setUpAllowed('Set Up Coarse Location', { ...w, inDialog: true })).toBe(false);
|
||||
expect(setUpAllowed('Set Up Coarse Location', { ...w, privacyPage: false })).toBe(false);
|
||||
expect(setUpTexts('Purchase History')).toContain('Set Up Purchases');
|
||||
});
|
||||
test('other forms: Next is navigation; the content rating\'s Save draft is clicked; Save, Submit, Create, Done stay the owner\'s', () => {
|
||||
const w = at({ privacyPage: false });
|
||||
for (const f of ['play-content-rating', 'play-target-audience', 'apple-app-record', 'play-app-record'] as const) {
|
||||
|
|
@ -148,14 +157,15 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
|
|||
expect((await run).fallback).toBe(false);
|
||||
}, TEST_MS);
|
||||
|
||||
test('answers exist: Edit next to Data Types (never the Privacy Policy Edit), then every type', async () => {
|
||||
test('answers exist and the list differs from the config: Edit next to Data Types (never the Privacy Policy Edit), then every type', async () => {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-flow.html?existing=1', ...fast(log) }));
|
||||
const config = privacy({ COARSE_LOCATION: { collected: true, purposes: ['ANALYTICS'], linked: false, tracking: false } });
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html?existing=1', ...fast(log) }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
||||
expect((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1);
|
||||
expect(log.lines.some(m => m.startsWith('Data collection was answered before'))).toBe(true);
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(4); // the list + three types
|
||||
expect(await clicks(b)).not.toContain('Publish');
|
||||
expect(navAsked(log.lines)).toEqual([]);
|
||||
await b.context.close();
|
||||
|
|
@ -197,6 +207,122 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
|
|||
}, TEST_MS);
|
||||
});
|
||||
|
||||
/** Six collected types as on the real page (Purchase History shows as "Purchases"), every answer certain. */
|
||||
const SIX: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {
|
||||
COARSE_LOCATION: { collected: true, purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'], linked: false, tracking: false },
|
||||
EMAILS_OR_TEXT_MESSAGES: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: true, tracking: false },
|
||||
DEVICE_ID: { collected: true, purposes: ['THIRD_PARTY_ADVERTISING', 'ANALYTICS'], linked: false, tracking: true },
|
||||
PURCHASE_HISTORY: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: true, tracking: false },
|
||||
PRODUCT_INTERACTION: { collected: true, purposes: ['ANALYTICS', 'PRODUCT_PERSONALIZATION'], linked: false, tracking: false },
|
||||
CRASH_DATA: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: false, tracking: false },
|
||||
};
|
||||
const six = (over: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {}) => privacy({ CRASH_DATA: SIX.CRASH_DATA!, ...SIX, ...over });
|
||||
const SIX_NAMES = ['Coarse Location', 'Emails or Text Messages', 'Device ID', 'Purchase History', 'Product Interaction', 'Crash Data'];
|
||||
const SETUP = 'apple-app-privacy-setup.html';
|
||||
const answersOf = (b: ConsoleBrowser) => b.page.evaluate(() => (window as any).__answers);
|
||||
|
||||
describe.skipIf(!browser)('App Privacy: each data type set up from the real page ("Set Up <type>" as plain text; fixture apple-app-privacy-setup.html)', () => {
|
||||
test('every collected type is set up through its dialog (purposes > Next > linked > Next > tracking > Save); "?", Publish and the Edits never clicked; every step dumped', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
||||
try {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: base + SETUP, ...fast(log), debugDir: dir, debugEveryStep: true }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
||||
expect(await b.page.evaluate(() => (window as any).__setups)).toEqual(SIX_NAMES.map(n => `Set Up ${n}`));
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6);
|
||||
const got = await answersOf(b);
|
||||
expect(got['Device ID']).toEqual({ purposes: ['Third-Party Advertising', 'Analytics'], linked: 'no', tracking: 'yes' });
|
||||
expect(got['Purchase History']).toEqual({ purposes: ['App Functionality'], linked: 'yes', tracking: 'no' });
|
||||
expect(got['Product Interaction']).toEqual({ purposes: ['Analytics', 'Product Personalization'], linked: 'no', tracking: 'no' });
|
||||
expect(Object.keys(got).sort()).toEqual([...SIX_NAMES].sort());
|
||||
const c = await clicks(b);
|
||||
for (const never of ['?', 'Publish', 'Edit', 'Cancel', 'Back']) expect(c).not.toContain(never);
|
||||
expect(c.filter(x => x === 'Next')).toHaveLength(12);
|
||||
expect(c.filter(x => x === 'Save')).toHaveLength(6);
|
||||
expect(log.lines).toContain('the Data Types section lists exactly the collected types of the config: storebound sets up each one');
|
||||
expect(log.lines).toContain('clicked "Set Up Purchase History" (navigation)');
|
||||
expect(log.lines.some(m => m.startsWith('answer needed'))).toBe(false);
|
||||
expect(navAsked(log.lines)).toEqual([]);
|
||||
// --debug-dump: the start, each Set Up, each dialog step and each filled step
|
||||
const names = readdirSync(dir);
|
||||
for (const want of [/-after-set-up-coarse-location$/, /-coarse-location-after-next$/, /-filled-coarse-location-purposes$/, /-filled-coarse-location-linked-to-/, /-filled-crash-data-tracking$/])
|
||||
expect(names.some(n => want.test(n))).toBe(true);
|
||||
const step = names.find(n => /-coarse-location-after-next$/.test(n))!;
|
||||
expect(JSON.parse(readFileSync(join(dir, step, 'page.json'), 'utf8')).dialogs).toContain('Coarse Location');
|
||||
await b.context.close();
|
||||
expect((await run).fallback).toBe(false);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}, TEST_MS);
|
||||
|
||||
test('an uncertain answer ("you decide") pauses on its step, names it, and goes on once the owner set it', async () => {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const config = six({ PURCHASE_HISTORY: { collected: true, purposes: ['APP_FUNCTIONALITY'], tracking: false } }); // linked: the owner's
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + SETUP, ...fast(log) }));
|
||||
await log.wait('the stop', l => l.some(m => m.startsWith('answer needed on "Purchase History: linked to the user": Purchase History: linked to the user\'s identity?')));
|
||||
await Bun.sleep(600); // several rounds: Next is not clicked past the open question
|
||||
const yes = b.page.getByRole('radio', { name: /^Yes, purchase history .* linked/ });
|
||||
expect(await yes.isVisible()).toBe(true);
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
||||
await yes.check(); // the owner answers
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
||||
expect(log.lines).toContain('"Purchase History: linked to the user" answered: storebound goes on');
|
||||
expect((await answersOf(b))['Purchase History'].linked).toBe('yes');
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6);
|
||||
expect(await clicks(b)).not.toContain('Publish');
|
||||
expect(await clicks(b)).not.toContain('?');
|
||||
await b.context.close();
|
||||
await run;
|
||||
}, TEST_MS);
|
||||
|
||||
test('an unknown dialog step: a debug dump, a pause that names the data type, within the step timeout; nothing clicked past it', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
||||
try {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const started = Date.now();
|
||||
const run = scope.run(fillSession(b, {
|
||||
form: 'apple-app-privacy', config: six(), startUrl: `${base}${SETUP}?unknown=Device%20ID`, ...fast(log),
|
||||
stepTimeoutMs: 1500, idleLimitMs: 60_000, heartbeatMs: 700, debugDir: dir,
|
||||
}));
|
||||
await log.wait('the pause', l => l.some(m => m.startsWith('paused on "Device ID"')));
|
||||
expect(Date.now() - started).toBeLessThan(30_000);
|
||||
const said = log.lines.join('\n');
|
||||
expect(said).toMatch(/the "Device ID" dialog step after "Next" is not a page storebound knows .*Buttons in the dialog: "Back", "Cancel", "Next"/);
|
||||
expect(said).toContain(`Debug saved to ${dir}`);
|
||||
const sub = readdirSync(dir).find(n => /unknown/.test(n))!;
|
||||
const pj = JSON.parse(readFileSync(join(dir, sub, 'page.json'), 'utf8'));
|
||||
expect(pj.dialogs).toContain('Device ID');
|
||||
expect(pj.controls).toContainEqual({ role: 'switch', name: 'Account identity', checked: false });
|
||||
await Bun.sleep(500);
|
||||
const c = await clicks(b);
|
||||
expect(c.filter(x => x === 'Next')).toHaveLength(2 * 2 + 1); // two types done, Device ID's first step only
|
||||
expect(c).not.toContain('Publish');
|
||||
expect(c).not.toContain('?');
|
||||
await b.context.close();
|
||||
expect((await run).fallback).toBe(false);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}, TEST_MS);
|
||||
|
||||
test('types set up before: the same purposes are left as they are, other purposes are opened with their Edit', async () => {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const url = `${base}${SETUP}?preset=${encodeURIComponent('Crash Data:App Functionality')}&preset=${encodeURIComponent('Device ID:Analytics')}`;
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: url, ...fast(log) }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
||||
expect(log.lines).toContain('Crash Data was set up before with the purposes of the config: left as it is');
|
||||
expect(log.lines).toContain('Device ID was set up before with other purposes than the config: storebound opens it with its Edit');
|
||||
const got = await answersOf(b);
|
||||
expect(got['Device ID'].purposes).toEqual(['Third-Party Advertising', 'Analytics']);
|
||||
expect(got['Crash Data']).toBeUndefined();
|
||||
expect((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1); // Device ID's own, never the section's
|
||||
expect(await clicks(b)).not.toContain('Publish');
|
||||
await b.context.close();
|
||||
await run;
|
||||
}, TEST_MS);
|
||||
});
|
||||
|
||||
describe.skipIf(!browser)('the other forms: navigation by itself, final buttons and draft saves left to the owner', () => {
|
||||
test('content rating: Save draft and Next by itself on each page, a stop at the uncertain questions until answered, the Summary; Submit never clicked', async () => {
|
||||
const b = await local();
|
||||
|
|
@ -265,7 +391,7 @@ describe.skipIf(!browser)('watchdog: an unknown page after a click never means a
|
|||
const said = log.lines.join('\n');
|
||||
expect(said).toContain('clicked "Next" (navigation)');
|
||||
expect(said).toMatch(/still waiting for the page after "Next", \d+ s/);
|
||||
expect(said).toMatch(/the page after "Next" is not a page storebound knows .*Buttons there: .*"Done"/);
|
||||
expect(said).toMatch(/the page after "Next" is not a page storebound knows .*Buttons in the dialog: .*"Done"/);
|
||||
expect(said).toContain(`Debug saved to ${dir}`);
|
||||
expect(said).toContain('do this part yourself');
|
||||
expect(said).toContain('it stops here');
|
||||
|
|
|
|||
105
tests/fixtures/console/apple-app-privacy-setup.html
vendored
Normal file
105
tests/fixtures/console/apple-app-privacy-setup.html
vendored
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<!doctype html>
|
||||
<!-- App Store Connect > App Privacy after the data types were saved. The PAGE mirrors the STRUCTURE of a real console
|
||||
page (sanitized debug dump, October 2026; structure only, no names, ids or data copied): an <h2> "App Privacy" with
|
||||
Publish next to it; an <h3> "Privacy Policy" with its Edit and a "?" button; an <h3> "Data Types" in its own box with
|
||||
its Edit and a one-line summary; then, in the NEXT box (not under the heading's box), the category names and one
|
||||
row per data type as plain <p> texts: the type label, a "?" help button (aria-label "More information") and
|
||||
"Set Up <type>", a <p> inside a div with the click handler (no button role, no link). Purchase History's row has
|
||||
only its "Set Up" (the console shows the category "Purchases" above it).
|
||||
The per-type DIALOG is MODELED from Apple's help page "Manage app privacy"
|
||||
(https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy: for each data type,
|
||||
the purposes, whether it is linked to the user's identity, whether it is used for tracking; Next between, Save at the
|
||||
end) in the markup of the real Data Collection dialog: <dialog role="dialog"> titled by <h2><span>, checkboxes whose
|
||||
<label> holds the label and its help text as two <p>, radios "<strong>Yes</strong>, ..." / "<strong>No</strong>, ...",
|
||||
Back / Cancel / Next / Save. Saving shows the type's purposes in its row and an Edit instead of "Set Up".
|
||||
?unknown=<type>: that type's second step is one storebound does not know (switches). ?preset=<type>:<purpose>|...
|
||||
(repeatable): that type was set up before with these purposes.
|
||||
window.__setups: the "Set Up" texts clicked; window.__answers: what each Save stored; window.__saves. -->
|
||||
<html lang="en"><head><meta charset="utf-8"><title>App Store Connect</title><script src="/_common.js"></script></head>
|
||||
<body>
|
||||
<nav aria-label="Apps"><ul><li><a href="#">App Information</a></li><li><a href="#">App Privacy</a></li><li><a href="#">Pricing and Availability</a></li></ul></nav>
|
||||
<main>
|
||||
<div><div><h2>App Privacy</h2></div><div><button type="button" id="publish">Publish</button></div></div>
|
||||
<hr>
|
||||
<div>
|
||||
<div><h3><span>Privacy Policy</span></h3><button role="button" type="button" id="pp-edit">Edit</button>
|
||||
<div><button role="button" type="button" aria-expanded="false" aria-haspopup="menu">English (U.S.)</button><button type="button" role="button">?</button></div></div>
|
||||
<div><div><label for="privacyPolicyUrl">Privacy Policy URL</label><button type="button" role="button" aria-label="More information">?</button></div><p>https://example.com/privacy</p></div>
|
||||
</div>
|
||||
<hr>
|
||||
<div><div><h3><span>Data Types</span></h3><button role="button" type="button" id="dt-edit">Edit</button></div><ul><li id="summary"></li></ul></div>
|
||||
<div id="grid"></div>
|
||||
</main>
|
||||
<div id="dialogs"></div>
|
||||
<script>
|
||||
// [category, type label in the row (null: the row has only "Set Up"), the type in "Set Up <type>"]
|
||||
const ROWS = [
|
||||
['Location', 'Coarse Location', 'Coarse Location'],
|
||||
['User Content', 'Emails or Text Messages', 'Emails or Text Messages'],
|
||||
['Identifiers', 'Device ID', 'Device ID'],
|
||||
['Purchases', null, 'Purchase History'],
|
||||
['Usage Data', 'Product Interaction', 'Product Interaction'],
|
||||
['Diagnostics', 'Crash Data', 'Crash Data'],
|
||||
];
|
||||
const PURPOSES = ['Third-Party Advertising', "Developer's Advertising or Marketing", 'Analytics', 'Product Personalization', 'App Functionality', 'Other Purposes'];
|
||||
const q = new URLSearchParams(location.search);
|
||||
const unknown = q.get('unknown');
|
||||
const preset = Object.fromEntries(q.getAll('preset').map(v => { const [t, ps] = v.split(':'); return [t, ps ? ps.split('|') : []]; }));
|
||||
const $ = id => document.getElementById(id);
|
||||
window.__setups = []; window.__answers = {}; window.__saves = 0;
|
||||
$('summary').textContent = `${ROWS.length} data types collected from this app: ${ROWS.map(r => r[2]).join(', ')}`;
|
||||
let cat = null;
|
||||
for (const [c, label, type] of ROWS) {
|
||||
if (c !== cat) { cat = c; const h = document.createElement('div'); h.innerHTML = `<hr><div><div></div><p>${c}</p></div>`; $('grid').append(h); }
|
||||
const row = document.createElement('div');
|
||||
row.dataset.type = type;
|
||||
row.innerHTML = (label ? `<div><p>${label}</p><div><button tabindex="0" role="button" type="button" aria-expanded="false" aria-label="More information">?</button></div></div>` : '') + '<div class="action"></div>';
|
||||
$('grid').append(row);
|
||||
if (preset[type]) setUpDone(type, preset[type]); else setUpOpen(type);
|
||||
}
|
||||
function setUpOpen(type) {
|
||||
const a = document.querySelector(`[data-type="${type}"] .action`);
|
||||
a.innerHTML = `<p>Set Up ${type}</p>`;
|
||||
a.onclick = () => { window.__setups.push(a.textContent.trim()); openType(type); };
|
||||
}
|
||||
function setUpDone(type, purposes) {
|
||||
const a = document.querySelector(`[data-type="${type}"] .action`);
|
||||
a.onclick = null;
|
||||
a.innerHTML = `<p>${purposes.join(', ')}</p><button type="button" role="button">Edit</button>`;
|
||||
a.querySelector('button').addEventListener('click', () => openType(type));
|
||||
}
|
||||
// the per-type dialog: purposes > Next > linked > Next > tracking > Save
|
||||
function openType(type) {
|
||||
const low = type.toLowerCase();
|
||||
const d = document.createElement('dialog');
|
||||
d.setAttribute('role', 'dialog'); d.setAttribute('aria-modal', 'true'); d.setAttribute('aria-labelledby', 'modal-header');
|
||||
const radio = (name, v, html) => `<div><input type="radio" name="${name}" id="${name}_${v}" value="${v}"><div><label for="${name}_${v}"><span>${html}</span></label></div></div>`;
|
||||
const step2 = unknown === type
|
||||
? `<p>Which kinds of identity does ${low} carry?</p><span role="switch" aria-checked="false" tabindex="0">Account identity</span><span role="switch" aria-checked="false" tabindex="0">Device identity</span>`
|
||||
: radio('linked', 'yes', `<strong>Yes</strong>, ${low} collected from this app is linked to the user’s identity`) + radio('linked', 'no', `<strong>No</strong>, ${low} collected from this app is not linked to the user’s identity`);
|
||||
d.innerHTML = `<div role="document"><h2 id="modal-header"><span>${type}</span></h2>`
|
||||
+ `<div data-p="1"><p>Indicate how ${low} collected from this app is being used by you or your third-party partners.</p>`
|
||||
+ PURPOSES.map((p, i) => `<div><input type="checkbox" id="purpose_${i}"><div><label for="purpose_${i}"><p>${p}</p><p>Description of ${p}</p></label></div></div>`).join('')
|
||||
+ `<div><button type="button">Cancel</button><button type="button" data-go="2">Next</button></div></div>`
|
||||
+ `<div data-p="2" hidden>${step2}<div><button type="button" data-go="1">Back</button><button type="button">Cancel</button><button type="button" data-go="3">Next</button></div></div>`
|
||||
+ `<div data-p="3" hidden>`
|
||||
+ radio('tracking', 'yes', `<strong>Yes</strong>, we use ${low} for tracking purposes`) + radio('tracking', 'no', `<strong>No</strong>, we do not use ${low} for tracking purposes`)
|
||||
+ `<div><button type="button" data-go="2">Back</button><button type="button">Cancel</button><button type="button" data-save>Save</button></div></div></div>`;
|
||||
d.setAttribute('open', '');
|
||||
d.addEventListener('click', e => {
|
||||
const go = e.target.closest('[data-go]')?.getAttribute('data-go');
|
||||
if (go) d.querySelectorAll('[data-p]').forEach(p => { p.hidden = p.getAttribute('data-p') !== go; });
|
||||
if (e.target.closest('button')?.textContent === 'Cancel') d.remove();
|
||||
if (e.target.closest('[data-save]')) {
|
||||
const purposes = PURPOSES.filter((p, i) => d.querySelector(`#purpose_${i}`).checked);
|
||||
const val = n => d.querySelector(`input[name="${n}"]:checked`)?.value ?? null;
|
||||
window.__answers[type] = { purposes, linked: val('linked'), tracking: val('tracking') };
|
||||
window.__saves++;
|
||||
d.remove();
|
||||
setUpDone(type, purposes);
|
||||
}
|
||||
});
|
||||
$('dialogs').append(d);
|
||||
}
|
||||
</script>
|
||||
</body></html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue