Files
builder/scripts/test-experiments.mjs
Reynaldi ChernandoandClaude Opus 5.5 ba262caa8e fast mode (#169)
* fast mode

* Fast mode on every plan, and the hint after 30 seconds

- Any signed-in account can switch fast mode on. The Upgrade button in
  its panel, the free plan's Upgrade hint, the plan check at turn start
  and the Fast mode tile in the Upgrade dialog are gone.
- The long-build hint shows once a build has run 30 seconds, down from
  45, still once per browser and never on a project's first build.
- Fast Mode Opened carries on and mid_build instead of access, and Fast
  Mode Hint Shown has no props.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* improvements

* Keep the fast mode hint and switch right on phones, clarify cards and full storage

- A phone's app view covers the chat without removing it from the layout,
  so the hint now waits for the chat view, and the hint, pointer and panel
  go when the user switches back to the app.
- The clarify card showed Send mid-turn, which told fast mode the turn had
  ended and dropped the hint's timer. It now gets the real turn state, and
  the hint waits while a clarify card waits on the user.
- With storage that reads but won't take a write (full), the switch and the
  hint's count now hold for the load instead of the old stored value.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-12 13:02:30 +07:00

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 \}/.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');