mirror of
https://github.com/HeyPuter/builder.git
synced 2026-11-13 18:33:58 -10:00
Upgrade After Limit and Unblocked After Limit now say whether the build that ran out of credits ran fast through the user's fast mode, as Usage Limit Hit already does, so upgrades can be compared for fast and usual builds. Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
205 lines
11 KiB
JavaScript
205 lines
11 KiB
JavaScript
import fs from 'node:fs';
|
|
|
|
// ---- experiment(), every A/B test in one place (src/js/experiments.js) --------
|
|
// Runs the real file against stubbed browser globals, once per "page load":
|
|
// the console API (the printed list, set, reset, and the warnings for a value
|
|
// or name that doesn't exist), and the Low Balance dialog cohort, which moved
|
|
// here from app.js and must put every account in the same cohort as before.
|
|
// The starter and speed tests have their own files.
|
|
|
|
let failures = 0;
|
|
function check(name, cond) {
|
|
if (cond) console.log('ok - ' + name);
|
|
else { console.error('FAIL - ' + name); failures++; }
|
|
}
|
|
|
|
const SRC = fs.readFileSync(new URL('../src/js/experiments.js', import.meta.url), 'utf8');
|
|
|
|
function memoryStorage(seed = {}) {
|
|
const m = new Map(Object.entries(seed));
|
|
return {
|
|
getItem: k => (m.has(k) ? m.get(k) : null),
|
|
setItem: (k, v) => { m.set(k, String(v)); },
|
|
removeItem: k => { m.delete(k); },
|
|
_map: m,
|
|
};
|
|
}
|
|
const blocked = {
|
|
getItem() { throw new Error('SecurityError'); },
|
|
setItem() { throw new Error('SecurityError'); },
|
|
removeItem() { throw new Error('SecurityError'); },
|
|
};
|
|
|
|
function load({ referrer = '', search = '', local = memoryStorage(), random = 0.1, puter, user = null } = {}) {
|
|
const warnings = [], tables = [];
|
|
const win = { location: { search }, user };
|
|
const env = {
|
|
window: win,
|
|
document: { referrer },
|
|
sessionStorage: memoryStorage(),
|
|
localStorage: local,
|
|
Math: Object.assign(Object.create(Math), { random: () => random }),
|
|
URL, URLSearchParams,
|
|
console: { warn: m => warnings.push(String(m)), table: t => tables.push(t) },
|
|
puter,
|
|
};
|
|
new Function('env', `with (env) {\n${SRC}\n}`)(env);
|
|
return { experiment: win.experiment, experimentProps: win.experimentProps, win, local, warnings, tables };
|
|
}
|
|
|
|
// The starter test is the running one these use. A visitor from
|
|
// developer.puter.com is in it, and random < 0.5 draws 'developer'.
|
|
const DEV = 'https://developer.puter.com/';
|
|
|
|
// ---- The printed list -----------------------------------------------------------
|
|
|
|
{
|
|
const h = load({ referrer: DEV, random: 0.1 });
|
|
const ret = h.experiment();
|
|
const t = h.tables[0] || {};
|
|
check('experiment() prints one table and returns nothing', h.tables.length === 1 && ret === undefined);
|
|
check('…with every test', JSON.stringify(Object.keys(t)) === '["starter","speed","limitDialog"]');
|
|
check('…the valid values of each', t.speed.valid === 'fast | standard' && t.starter.valid === 'developer | default' && t.limitDialog.valid === 'custom | default');
|
|
check('…your variant and that it was drawn', t.starter.variant === 'developer' && t.starter.how === 'drawn');
|
|
check('…the tests you are outside of', t.limitDialog.variant === 'app' && t.limitDialog.how === 'not in test');
|
|
check('…and the ones that ended', t.speed.variant === null && t.speed.how === 'ended');
|
|
const out = load();
|
|
out.experiment();
|
|
check('a visitor from elsewhere is outside the starter test', out.tables[0].starter.variant === null && out.tables[0].starter.how === 'not in test');
|
|
}
|
|
|
|
// ---- set and reset --------------------------------------------------------------
|
|
|
|
{
|
|
const h = load({ referrer: DEV, random: 0.1 });
|
|
check('starter draws developer here', h.experiment('starter') === 'developer');
|
|
h.experiment.set('starter', 'default');
|
|
check('set forces a variant', h.experiment('starter') === 'default' && h.warnings.length === 0);
|
|
check('…kept under experimentOverrides', h.local.getItem('experimentOverrides') === '{"starter":"default"}');
|
|
check('…without touching the drawn one', h.local.getItem('starterTestArm') === 'developer');
|
|
h.experiment();
|
|
check('the list says it is forced', h.tables[0].starter.variant === 'default' && h.tables[0].starter.how === 'forced');
|
|
check('events report the forced variant', JSON.stringify(h.experimentProps('starter')) === '{"pill_variant":"default"}');
|
|
|
|
check('a later load keeps the forced variant', load({ referrer: DEV, local: h.local, random: 0.9 }).experiment('starter') === 'default');
|
|
|
|
h.experiment.set('starter', 'Developer');
|
|
check('a variant that does not exist warns with the valid ones', /starter has no variant "Developer"\. Valid: developer, default/.test(h.warnings.at(-1) || ''));
|
|
check('…and changes nothing', h.experiment('starter') === 'default');
|
|
|
|
h.experiment.set('startr', 'developer');
|
|
check('a test that does not exist warns with the known ones', /no "startr"\. Known: starter, speed, limitDialog/.test(h.warnings.at(-1) || ''));
|
|
check('…and stores nothing for it', h.local.getItem('experimentOverrides') === '{"starter":"default"}');
|
|
|
|
const before = h.warnings.length;
|
|
check('reading a test that does not exist warns and gives null', h.experiment('startr') === null && h.warnings.length === before + 1);
|
|
check('…and its props are empty', JSON.stringify(h.experimentProps('startr')) === '{}');
|
|
check('Object built-ins are not tests', h.experiment('toString') === null && h.experiment('__proto__') === null);
|
|
|
|
h.experiment.set('limitDialog', 'custom');
|
|
h.experiment.reset('starter');
|
|
check('reset(name) goes back to the drawn variant', h.experiment('starter') === 'developer');
|
|
check('…and leaves the other overrides', h.local.getItem('experimentOverrides') === '{"limitDialog":"custom"}');
|
|
h.experiment.reset();
|
|
check('reset() clears every override', h.local.getItem('experimentOverrides') === null && h.experiment('limitDialog') === 'app');
|
|
}
|
|
|
|
{
|
|
const h = load({ referrer: DEV, local: memoryStorage({ experimentOverrides: 'not json' }) });
|
|
check('unreadable overrides are ignored', h.experiment('starter') === 'developer');
|
|
const g = load({ referrer: DEV, random: 0.9, local: memoryStorage({ experimentOverrides: '{"starter":"Developer","speed":"fast"}' }) });
|
|
check('a stored override that is not a variant is ignored', g.experiment('starter') === 'default');
|
|
check('…while the valid one beside it still counts', g.experiment('speed') === 'fast');
|
|
const arr = load({ referrer: DEV, local: memoryStorage({ experimentOverrides: '["default"]' }) });
|
|
check('an override list that is not an object is ignored', arr.experiment('starter') === 'developer');
|
|
}
|
|
|
|
{
|
|
let threw = false, h = null;
|
|
try {
|
|
h = load({ referrer: DEV, local: blocked, random: 0.9 });
|
|
h.experiment.set('starter', 'developer');
|
|
h.experiment.reset();
|
|
h.experiment();
|
|
} catch (e) { threw = true; }
|
|
check('blocked storage never throws', !threw);
|
|
check('…set says nothing could be forced', h && /storage is blocked/.test(h.warnings.join('\n')));
|
|
check('…and the variant is still drawn', h && h.experiment('starter') === 'default');
|
|
}
|
|
|
|
// ---- An ended test (speed) --------------------------------------------------------
|
|
|
|
{
|
|
const h = load({ random: 0.1 });
|
|
check('an ended test puts nobody in it', h.experiment('speed') === null);
|
|
check('…so its events carry nothing', JSON.stringify(h.experimentProps('speed')) === '{}');
|
|
check('…and nothing is drawn or stored for it', h.local.getItem('speedTestArm') === null);
|
|
check('a browser drawn before it ended is out of it too',
|
|
load({ local: memoryStorage({ speedTestArm: 'fast' }) }).experiment('speed') === null);
|
|
h.experiment.set('speed', 'fast');
|
|
check('a forced variant still applies, to try it by hand',
|
|
h.experiment('speed') === 'fast' && JSON.stringify(h.experimentProps('speed')) === '{"speed_variant":"fast"}');
|
|
h.experiment();
|
|
check('…and the list says it is forced', h.tables[0].speed.how === 'forced');
|
|
}
|
|
|
|
// ---- limitDialog: the same cohort as before it moved ----------------------------
|
|
|
|
{
|
|
const web = (uuid, local) => load({ puter: { env: 'web' }, user: uuid ? { uuid } : null, local }).experiment('limitDialog');
|
|
// What app.js's limitDialogCohort() returned for these accounts.
|
|
const BEFORE = {
|
|
'user-a': 'default',
|
|
'user-b': 'custom',
|
|
'0b6f3c2e-9a41-4d8e-b0f7-1c2d3e4f5a6b': 'custom',
|
|
'd1e2f3a4-b5c6-4789-90ab-cdef01234567': 'custom',
|
|
'f00dbabe-0000-4000-8000-000000000001': 'default',
|
|
'7c9e6679-7425-40de-944b-e07fc1f90ae7': 'custom',
|
|
'a': 'default',
|
|
'zz': 'custom',
|
|
};
|
|
check('every account keeps the cohort it had', Object.entries(BEFORE).every(([id, cohort]) => web(id) === cohort));
|
|
check('no puter / desktop app → app cohort', load().experiment('limitDialog') === 'app' && load({ puter: { env: 'app' } }).experiment('limitDialog') === 'app');
|
|
check('…and its events still say app', JSON.stringify(load({ puter: { env: 'app' } }).experimentProps('limitDialog')) === '{"cohort":"app"}');
|
|
check('web without a user → default', web(null) === 'default');
|
|
check('cohort is stable per account', web('user-a') === web('user-a'));
|
|
let custom = 0;
|
|
for (let i = 0; i < 2000; i++) if (web('u-' + i + '-x') === 'custom') custom++;
|
|
check(`cohorts split about evenly (${custom}/2000 custom)`, custom > 900 && custom < 1100);
|
|
const st = memoryStorage();
|
|
const h = load({ puter: { env: 'web' }, user: { uuid: 'user-a' }, local: st });
|
|
h.experiment.set('limitDialog', 'custom');
|
|
const forcedCustom = h.experiment('limitDialog');
|
|
h.experiment.set('limitDialog', 'default');
|
|
check('set forces the cohort', forcedCustom === 'custom' && h.experiment('limitDialog') === 'default');
|
|
const desk = load({ puter: { env: 'app' }, local: memoryStorage({ experimentOverrides: '{"limitDialog":"custom"}' }) });
|
|
check('a forced cohort does not apply inside the desktop', desk.experiment('limitDialog') === 'app'
|
|
&& JSON.stringify(desk.experimentProps('limitDialog')) === '{"cohort":"app"}');
|
|
desk.experiment();
|
|
check('…and the list says it is not in the test', desk.tables[0].limitDialog.how === 'not in test');
|
|
check('the cohort follows the user who signs in later', (() => {
|
|
const late = load({ puter: { env: 'web' } });
|
|
const first = late.experiment('limitDialog');
|
|
late.win.user = { uuid: 'user-b' };
|
|
return first === 'default' && late.experiment('limitDialog') === 'custom';
|
|
})());
|
|
}
|
|
|
|
// ---- Wiring -------------------------------------------------------------------
|
|
|
|
const read = p => fs.readFileSync(new URL(p, import.meta.url), 'utf8');
|
|
const VITE = read('../vite.config.js');
|
|
const at = f => VITE.indexOf(`'js/${f}'`);
|
|
check('loaded before ui.js and app.js', at('experiments.js') > 0 && at('experiments.js') < at('ui.js') && at('experiments.js') < at('app.js'));
|
|
const APP = read('../src/js/app.js'), UI = read('../src/js/ui.js');
|
|
check('the old per-test code and debug keys are gone',
|
|
!/limitDialogCohort|starterTest\b|starterTestProps|speedTest\b|speedTestProps|debugLimitCohort|debugStarterArm|debugSpeedArm/.test(APP + UI));
|
|
check('the limit hit and its pay record carry the cohort',
|
|
/cohort: window\.experiment\('limitDialog'\),\n\s*\.\.\.templateProp\(history\)/.test(APP)
|
|
&& /plan, cohort: window\.experiment\('limitDialog'\), template, fast_mode: !!fastMode \}/.test(APP));
|
|
check('the Low Balance dialog and its funnel read the cohort',
|
|
(UI.match(/const cohort = window\.experiment\('limitDialog'\);/g) || []).length === 2);
|
|
|
|
if (failures) { console.error(`\n${failures} check(s) failed`); process.exit(1); }
|
|
console.log('\nall experiment checks passed');
|