Skip to content
Closed
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
120 changes: 120 additions & 0 deletions scripts/dev/scroll-quota-ab-compare.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* A/B compare scroll quota stress: fixed vs upstream-main (broken #707 unwrap).
*
* Usage:
* node scripts/dev/scroll-quota-ab-compare.mjs --session <id>
*
* Env:
* HAPI_ACCESS_TOKEN — CLI token for auth
* HAPI_URL_FIXED — default http://127.0.0.1:3006 (fixed build)
* HAPI_URL_BROKEN — default http://127.0.0.1:3007 (upstream/main build)
*/
import { spawnSync } from 'node:child_process'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { writeFileSync, mkdirSync } from 'node:fs'

const __dir = dirname(fileURLToPath(import.meta.url))
const repro = resolve(__dir, 'scroll-quota-repro-playwright.mjs')

function parseArgs(argv) {
const args = { sessionId: '', fillMb: 4.5, scrollRoutes: 250, navRounds: 12 }
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === '--session') args.sessionId = argv[++i]
else if (arg === '--fill-mb') args.fillMb = Number(argv[++i])
else if (arg === '--scroll-routes') args.scrollRoutes = Number(argv[++i])
else if (arg === '--nav-rounds') args.navRounds = Number(argv[++i])
}
return args
}

const args = parseArgs(process.argv.slice(2))
const token = process.env.HAPI_ACCESS_TOKEN ?? ''
const fixedUrl = process.env.HAPI_URL_FIXED ?? 'http://127.0.0.1:3006'
const brokenUrl = process.env.HAPI_URL_BROKEN ?? 'http://127.0.0.1:3007'

function runVariant(label, baseUrl) {
const env = {
...process.env,
HAPI_URL: baseUrl,
HAPI_ACCESS_TOKEN: token,
}
const proc = spawnSync(
process.execPath,
[
repro,
'--session', args.sessionId,
'--fill-mb', String(args.fillMb),
'--scroll-routes', String(args.scrollRoutes),
'--nav-rounds', String(args.navRounds),
],
{ env, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 },
)
let parsed = null
try {
const stdout = proc.stdout.trim()
parsed = stdout
? JSON.parse(stdout)
: { ok: false, parseError: true, stdout: '', stderr: proc.stderr }
} catch {
parsed = { ok: false, parseError: true, stdout: proc.stdout.slice(-2000), stderr: proc.stderr }
}
return { label, baseUrl, exitCode: proc.status ?? 1, result: parsed }
}

if (!args.sessionId) {
console.error('Usage: scroll-quota-ab-compare.mjs --session <id>')
process.exit(2)
}

console.log('Running A/B scroll quota stress...')
console.log(` FIXED → ${fixedUrl}`)
console.log(` BROKEN → ${brokenUrl}`)
console.log('')

const fixed = runVariant('fixed (patched cache.set)', fixedUrl)
const broken = runVariant('upstream-main (#707 unwrap)', brokenUrl)

const summary = {
sessionId: args.sessionId,
params: args,
broken,
fixed,
regressionDetected: Boolean(broken.result?.ok === false && (broken.result?.quotaErrors?.length || broken.result?.pageErrors?.length)),
fixVerified: Boolean(fixed.result?.ok === true && !(fixed.result?.quotaErrors?.length)),
}

mkdirSync(resolve('localdocs/playwright-runs'), { recursive: true })
const out = resolve('localdocs/playwright-runs', `scroll-quota-ab-${Date.now()}.json`)
writeFileSync(out, JSON.stringify(summary, null, 2))

console.log(JSON.stringify({
broken: {
ok: broken.result?.ok,
quotaErrors: broken.result?.quotaErrors,
pageErrors: broken.result?.pageErrors,
scrollKeyAfter: broken.result?.scrollKeyAfter,
exitCode: broken.exitCode,
},
fixed: {
ok: fixed.result?.ok,
quotaErrors: fixed.result?.quotaErrors,
pageErrors: fixed.result?.pageErrors,
scrollKeyAfter: fixed.result?.scrollKeyAfter,
exitCode: fixed.exitCode,
},
regressionDetected: summary.regressionDetected,
fixVerified: summary.fixVerified,
report: out,
}, null, 2))

if (!summary.regressionDetected) {
console.error('\nNOTE: upstream-main did NOT fail this harness — may need harsher race trigger or pre-#707 tree.')
process.exitCode = 3
} else if (!summary.fixVerified) {
process.exitCode = 2
} else {
process.exitCode = 0
}
193 changes: 193 additions & 0 deletions scripts/dev/scroll-quota-prove-positive.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#!/usr/bin/env node
/**
* Positive repro: prove upstream scroll guard fails under quota while fixed code survives.
* Runs async scroll-key writes after filling sessionStorage (TanStack-like timing).
*/
import { chromium } from 'playwright'
import { mkdirSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'

const SCROLL_KEY = 'tsr-scroll-restoration-v1_3'
const BASE_URL = process.env.HAPI_URL ?? 'http://127.0.0.1:3007'
const ACCESS_TOKEN = process.env.HAPI_ACCESS_TOKEN ?? ''
const LABEL = process.env.HAPI_VARIANT ?? 'unknown'
const OUT_DIR = resolve('localdocs/playwright-runs')

function parseArgs(argv) {
const args = { sessionId: '', fillMb: 5.0, routes: 200 }
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--session') args.sessionId = argv[++i]
else if (argv[i] === '--fill-mb') args.fillMb = Number(argv[++i])
else if (argv[i] === '--routes') args.routes = Number(argv[++i])
}
return args
}

const args = parseArgs(process.argv.slice(2))
mkdirSync(OUT_DIR, { recursive: true })
const outJson = resolve(OUT_DIR, `scroll-quota-positive-${LABEL}-${Date.now()}.json`)

const browser = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROME_PATH ?? '/usr/bin/google-chrome',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
})
const context = await browser.newContext()
if (ACCESS_TOKEN) {
await context.addInitScript(({ token, baseUrl }) => {
localStorage.setItem(`hapi_access_token::${baseUrl}`, token)
}, { token: ACCESS_TOKEN, baseUrl: BASE_URL })
}

const page = await context.newPage()
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (err) => pageErrors.push(String(err)))
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text())
})

const path = args.sessionId ? `/sessions/${args.sessionId}` : '/sessions'
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
await page.waitForTimeout(2500)

const guardInstalled = await page.evaluate(() => Boolean(sessionStorage.__hapiScrollRestorationGuard))

// Fill storage until quota, leaving scroll key for last-mile pressure
const fill = await page.evaluate((fillMb) => {
const chunk = 'y'.repeat(256 * 1024)
const target = fillMb * 1024 * 1024
let added = 0
let i = 0
const errors = []
while (added < target) {
try {
sessionStorage.setItem(`__fill_${i}`, chunk)
added += chunk.length
i += 1
} catch (e) {
errors.push(String(e))
break
}
}
return { keys: i, mb: added / (1024 * 1024), errors }
}, args.fillMb)

function buildPayload(routeCount) {
const state = {}
for (let i = 0; i < routeCount; i += 1) {
state[`/sessions/stress-${i}`] = {
window: { scrollX: 0, scrollY: i * 19 },
'main:nth-child(1)': { scrollX: 0, scrollY: i * 13 },
}
}
return JSON.stringify(state)
}

// Phase A: TanStack-like async scroll persists
const asyncWrites = await page.evaluate(({ scrollKey, routes }) => {
const payload = (() => {
const state = {}
for (let i = 0; i < routes; i += 1) {
state[`/sessions/stress-${i}`] = {
window: { scrollX: 0, scrollY: i * 19 },
'main:nth-child(1)': { scrollX: 0, scrollY: i * 13 },
}
}
return JSON.stringify(state)
})()

return new Promise((resolve) => {
const result = { scheduled: 0, syncThrows: [] }
window.__scrollQuotaPositive = { pageErrors: [] }
const onErr = (ev) => {
result.pageErrors = result.pageErrors ?? []
result.pageErrors.push(String(ev.error ?? ev.message))
}
window.addEventListener('error', onErr)

for (let n = 0; n < 25; n += 1) {
result.scheduled += 1
setTimeout(() => {
try {
sessionStorage.setItem(scrollKey, payload)
} catch (e) {
result.syncThrows.push(String(e))
}
}, n * 8)
}

setTimeout(() => {
window.removeEventListener('error', onErr)
resolve(result)
}, 400)
})
}, { scrollKey: SCROLL_KEY, routes: args.routes })

await page.waitForTimeout(300)

// Phase B: explicit #707 unwrap-window simulation (positive control for broken pattern)
const unwrapRace = await page.evaluate((scrollKey) => {
return new Promise((resolve) => {
const windowErrors = []
const onErr = (ev) => windowErrors.push(String(ev.error ?? ev.message))
window.addEventListener('error', onErr)

const wrapped = sessionStorage.setItem
const native = Storage.prototype.setItem.bind(sessionStorage)
sessionStorage.setItem = native

for (let i = 0; i < 12; i += 1) {
setTimeout(() => {
try {
const state = {}
for (let r = 0; r < 120; r += 1) {
state[`/race/${i}/${r}`] = { window: { scrollX: 0, scrollY: r } }
}
sessionStorage.setItem(scrollKey, JSON.stringify(state))
} catch {
// sync catch — window 'error' is what we care about (TanStack async path)
}
}, i * 5)
}

setTimeout(() => {
sessionStorage.setItem = wrapped
window.removeEventListener('error', onErr)
resolve({ windowErrors })
}, 250)
})
}, SCROLL_KEY)

await page.waitForTimeout(200)

const scrollKeyAfter = await page.evaluate((scrollKey) => {
const v = sessionStorage.getItem(scrollKey)
return v ? { bytes: v.length, routes: Object.keys(JSON.parse(v)).length } : { bytes: 0, routes: 0 }
}, SCROLL_KEY)

const quotaSignals = [
...pageErrors.filter((t) => /QuotaExceeded|tsr-scroll-restoration/i.test(t)),
...consoleErrors.filter((t) => /QuotaExceeded|tsr-scroll-restoration/i.test(t)),
...(asyncWrites.pageErrors ?? []).filter((t) => /QuotaExceeded|tsr-scroll-restoration/i.test(t)),
...(unwrapRace.windowErrors ?? []).filter((t) => /QuotaExceeded|tsr-scroll-restoration/i.test(t)),
]

const result = {
label: LABEL,
url: BASE_URL,
guardInstalled,
fill,
asyncWrites,
unwrapRace,
scrollKeyAfter,
pageErrors,
quotaSignals,
bugReproduced: quotaSignals.length > 0,
survived: quotaSignals.length === 0,
}

writeFileSync(outJson, JSON.stringify(result, null, 2))
console.log(JSON.stringify(result, null, 2))
await browser.close()
process.exitCode = result.bugReproduced ? 0 : 1
Loading
Loading