diff --git a/README.md b/README.md index f5d4387c..568f78d8 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ for saved retries and limits. New create/join requests save a private, tab-scoped retry record before sending. If the response is uncertain, keep the tab open and use the explicit retry; refresh preserves the same request. Confirmed success removes that record. Clearly rejected first requests return to the form for correction. A retry can recover the original room and seat without creating duplicates, but closing the tab or clearing its data can lose the proof. This record is separate from the long-term seat recovery kit. +Normal creation/join shows brief progress; recovery instructions stay open when +attention is needed. Completed-action notices wait for **Continue** so their text +can be read at your pace. See [readable notices](docs/READABLE_NOTICES.md). + An uncertain saved-kit recovery keeps its exact retry details in memory and prevents conflicting navigation. If that recovery has become obsolete, deliberate abandonment releases the controls after explaining that it cannot undo a completed recovery. Keep a valid saved kit before discarding retry details. Use **Pass your seat to another player** for a voluntary one-time transfer. The private offer lasts 24 hours; acceptance preserves progress and revokes the old owner’s sessions and recovery kits. The recipient uses **Accept a seat handover** on the home page, then creates their own recovery kit. Keep the tab open until acceptance is confirmed; exact private retry details survive refresh in that tab. See [seat handover](docs/SEAT_HANDOVER.md) for cancellation, retry behavior and current limits. diff --git a/app/page.tsx b/app/page.tsx index afb10945..13318c95 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -80,6 +80,7 @@ export default function Home() { null, ); const [entryProblem, setEntryProblem] = useState(''); + const [initialEntryPending, setInitialEntryPending] = useState(false); const [showEntryRecovery, setShowEntryRecovery] = useState(false); const [showHandover, setShowHandover] = useState(false); const [handoverPending, setHandoverPending] = useState(true); @@ -407,6 +408,7 @@ export default function Home() { ); } } finally { + if (firstDispatch) setInitialEntryPending(false); mutationPending.current = false; setBusy(false); } @@ -443,6 +445,7 @@ export default function Home() { return; } pendingEntry.current = attempt; + setInitialEntryPending(true); setEntryAttempt(attempt); setAbandonAcknowledged(false); await dispatchEntry(attempt, true); @@ -808,6 +811,20 @@ export default function Home() { ); + // A first request is progress, not recovery. Keep the actionable recovery + // instructions on screen only after uncertainty or a saved-request restore. + if (initialEntryPending && entryAttempt) + return ( +
+
+
DUNEARRAKIS TABLE
+
+
+

{entryAttempt.kind === 'create' ? 'Creating your room…' : 'Joining the room…'}

+ Please wait. +
+
+ ); if (entryAttempt || entryProblem) return (
diff --git a/components/automatic-action-notice.tsx b/components/automatic-action-notice.tsx index fc0b59bc..cc357b52 100644 --- a/components/automatic-action-notice.tsx +++ b/components/automatic-action-notice.tsx @@ -1,15 +1,13 @@ 'use client'; import { - useCallback, useEffect, - useRef, - useState, + useReducer, useSyncExternalStore, } from 'react'; import { Bell, BellOff } from 'lucide-react'; import { FACTIONS } from '@/game/catalog'; -import { advanceActionNotices, type AutomaticActionEvent, type QueuedActionNotice } from '@/lib/action-notice-queue'; +import { initialActionNotices, updateActionNotices, type AutomaticActionEvent, type QueuedActionNotice } from '@/lib/action-notice-queue'; import { Button } from './ui/button'; export type { AutomaticActionEvent } from '@/lib/action-notice-queue'; @@ -42,89 +40,28 @@ export function AutomaticActionNotice({ readPreference, serverPreference, ); - // The first snapshot establishes a baseline. Refreshing never replays history. - const seenSeq = useRef( - events.reduce((last, event) => Math.max(last, event.seq), -1), - ); - const queue = useRef([]); - const active = useRef(null); - const [notice, setNotice] = useState< - (QueuedActionNotice & { fading: boolean }) | null - >(null); - const [completed, setCompleted] = useState(0); - const timers = useRef[]>([]); - const clearTimers = useCallback(() => { - for (const timer of timers.current) clearTimeout(timer); - timers.current = []; - }, []); - - const showNext = useCallback(() => { - if (active.current) return; - if (!readPreference() || document.visibilityState === 'hidden') { - queue.current = []; - return; - } - const next = queue.current.shift(); - if (!next) return; - active.current = next; - clearTimers(); - timers.current = [ - setTimeout(() => { - setNotice({ ...next, fading: false }); - }, 0), - setTimeout(() => { - setNotice((current) => - current?.id === next.id ? { ...current, fading: true } : current, - ); - }, 1250), - setTimeout(() => { - active.current = null; - setNotice(null); - setCompleted((count) => count + 1); - }, 1500), - ]; - }, [clearTimers]); + const [presentation, updatePresentation] = useReducer(updateActionNotices, events, initialActionNotices); useEffect(() => { - // Preserve the visible notice. Summarize older pending actions so a bot - // batch cannot leave minutes of stale feedback; the chronicle keeps detail. - const next = advanceActionNotices({ seenSeq: seenSeq.current, pending: queue.current }, events, { - active: !!active.current, - show: readPreference() && document.visibilityState !== 'hidden', + updatePresentation({ + type: 'events', events, enabled, + visible: document.visibilityState !== 'hidden', }); - seenSeq.current = next.seenSeq; - queue.current = next.pending; - showNext(); - }, [events, showNext, completed]); + }, [events, enabled]); useEffect(() => { const hide = () => { if (document.visibilityState !== 'hidden') return; - clearTimers(); - queue.current = []; - active.current = null; - setNotice(null); + updatePresentation({ type: 'hide' }); }; document.addEventListener('visibilitychange', hide); return () => document.removeEventListener('visibilitychange', hide); - }, [clearTimers]); - - useEffect( - () => () => { - clearTimers(); - queue.current = []; - active.current = null; - }, - [clearTimers], - ); + }, []); const toggle = () => { const next = !enabled; memoryPreference = next; - clearTimers(); - queue.current = []; - active.current = null; - setNotice(null); + updatePresentation({ type: 'clear' }); try { localStorage.setItem(PREFERENCE_KEY, String(next)); memoryPreference = undefined; @@ -133,9 +70,6 @@ export function AutomaticActionNotice({ } window.dispatchEvent(new Event(PREFERENCE_EVENT)); }; - const house = notice - ? FACTIONS.find((candidate) => candidate.id === notice.faction) - : undefined; return ( <> @@ -155,26 +89,30 @@ export function AutomaticActionNotice({ aria-atomic="true" className="pointer-events-none fixed top-[max(1rem,env(safe-area-inset-top))] left-1/2 z-40 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2" > - {enabled && notice && ( - - - {house?.name ?? 'Automatic action'} - - - {notice.name} - - {notice.count > 1 && - {notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle. - } - + {enabled && presentation.active && ( + updatePresentation({ type: 'continue' })} /> )} ); } + +/** The notice alone accepts input; it does not trap or move keyboard focus. */ +export function ActionNoticeCard({ notice, onContinue }: { notice: QueuedActionNotice; onContinue: () => void }) { + const house = FACTIONS.find((candidate) => candidate.id === notice.faction); + return + + {house?.name ?? 'Automatic action'} + + {notice.name} + {notice.count > 1 && + {notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle. + } + + ; +} diff --git a/docs/ACTION_FEEDBACK.md b/docs/ACTION_FEEDBACK.md index 79717781..ea809287 100644 --- a/docs/ACTION_FEEDBACK.md +++ b/docs/ACTION_FEEDBACK.md @@ -15,30 +15,41 @@ its separate mute and volume settings still apply. ## Bursts, refresh and controls -Each notice lasts 1.5 seconds and does not intercept pointer or keyboard input. +Each notice stays visible until the player chooses **Continue** or switches +notices off. Continue dismisses only this local message; it does not submit a +game action, delay AI progress or pause a saved decision. Notices do not move +keyboard focus or open a modal. Only the notice itself intercepts pointer input; +the rest of the table remains usable. Regular text is at least 14 pixels, with +a 44-pixel minimum button height and scrolling for a short viewport. + The queue holds at most three notices including the visible one. When more actions arrive, older pending notices merge into a count beside the most recent action in that group; their individual details remain in the table chronicle. -A new burst never replaces the currently displayed notice. After arrivals stop, -the visible notice and pending queue finish within 4.5 seconds, rather than -retaining minutes of old events. +A new burst never replaces the currently displayed notice. Continue displays +the next queued notice, if any, until that notice is also dismissed. Initial and restored snapshots establish a silent baseline. Already-seen events do not replay. Events received while notices are off or the page is hidden are -consumed silently. Hiding the page clears any visible or queued notice. The +consumed silently. Hiding the page clears pending notices while preserving the +unread notice already displayed, so it is still there when the player returns. The **Automatic action notices** toggle remains cosmetic; it cannot submit a game -action or change a saved decision. Reduced motion removes the fade. +action or change a saved decision. Notices have no timed fade or dismissal. ## Verification boundary `tests/action-notice-queue.test.tsx` checks small updates, a full 250-entry burst, successive bursts during an active notice, exact represented counts and the initial nonblocking output, plus stale/duplicate sequence handling and silent -consumption without replay. `tests/completed-action-events.test.ts` exercises +consumption without replay. The local presentation tests exercise Continue, +unread text across bursts/backgrounding, mute and no replay; the rendered notice +test checks readable content and its connected Continue control without focus +or modal behavior. These tests do not mount a browser DOM. +`tests/completed-action-events.test.ts` exercises committed results and pending/canceled paths. Private checkpoint evidence records the browser checks and final source-bound verification. -Browser QA used genuine Basic Atreides/Harkonnen/Emperor setup. Private batches +Earlier browser QA, before the persistent Continue control, used genuine Basic +Atreides/Harkonnen/Emperor setup. Private batches advanced only that QA room through real engine actions, preserving card/force custody and seat controllers. A seven-event burst displayed the Emperor's **Revival** with four earlier completed actions, then drained. The chronicle @@ -48,10 +59,11 @@ left it empty. Refresh retained the exact saved game row. At a 390-pixel viewpor the notice container was 343 pixels within a 375-pixel document without horizontal overflow; transient mobile text was not captured. The viewport was reset afterward. -The provider reports background tabs as visible, so an actual background-page +That provider reported background tabs as visible, so an actual background-page transition was not exercised. The cursor tests and handler review cover that -branch; the automated component test does not mount timers. Existing screenshot -capture failures leave full visual acceptance open. +branch. That earlier timed-notice evidence does not verify the persistent +control; current browser acceptance belongs to the new checkpoint report. +Existing screenshot capture failures leave full visual acceptance open. This extends functional feedback; complete animation design, every successful power tag and full visual/device acceptance remain unfinished. It does not diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c36fe027..c0c2e5e9 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,6 +6,16 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work +The [readable-notice fix](READABLE_NOTICES.md) replaces the flashing recovery +screen during ordinary room creation/join with concise progress. Actual recovery +instructions stay until resolved, and completed-action text waits for a local +Continue control. It does not add confirmation steps to game rules or AI turns. + +Administrator access/directory checkpoint `c2d392e` passed types, lint, 5,196 +offline tests, 52 HTTP tests and build, plus focused authenticated/browser checks. +Its production acceptance is being combined with this readability follow-up to +avoid two deployments; saved games and full administration gates remain intact. + The user added a complete [administration panel](ADMIN_PANEL.md) to the goal on 22 September: secure admin access, room creation/removal and lifecycle controls, participant support, backup/restore, operational views and audited actions. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 9999e77b..899db359 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,15 @@ # Dune implementation status +## 22 September 2026 — Readable room entry and completed-action text + +Normal create/join now shows concise pending text while the first request runs; +the existing full recovery panel is reserved for saved or uncertain requests and +remains until explicitly resolved. Automatic-action notices retain text behind a +local Continue control without gating rules, server actions or AI pacing. See +[readable notices](READABLE_NOTICES.md) and [action feedback](ACTION_FEEDBACK.md). +Focused regressions, delayed/lost-response browser QA and required checkpoint +checks accompany the change. Deployed evidence remains separate and required. + ## 22 September 2026 — Administrator access and room directory prototype The first [admin panel](ADMIN_PANEL.md#access-and-directory-prototype) slice adds diff --git a/docs/READABLE_NOTICES.md b/docs/READABLE_NOTICES.md new file mode 100644 index 00000000..fc4252a9 --- /dev/null +++ b/docs/READABLE_NOTICES.md @@ -0,0 +1,40 @@ +# Readable progress, recovery and action notices + +The user reported that a popup after room creation disappeared before it could +be read or acted on. The first create/join request was rendering the full saved +request recovery panel while awaiting its initial response. Successful creation +then removed it immediately, making actionable recovery instructions appear to +flash on screen even when nothing had gone wrong. + +First submissions now show only **Creating your room…** or **Joining the room…** +and a short progress message. Successful responses still open the confirmed +table. A lost/uncertain response, saved request after refresh, or storage problem +shows the existing persistent recovery panel. Its exact retry, deliberate +abandonment and saved-kit recovery controls remain available until the player +resolves the request. The private retry proof is still saved before submission; +there is no automatic retry or new seat operation. + +Completed-action notices also retain their text until **Continue**, instead of +vanishing after 1.5 seconds. This is a local display control: the server has +already completed the action and human/AI play continues normally. New events +cannot replace the text being read. The optional toggle, bounded summaries, +private-information filtering and silent refresh baseline remain; see +[completed-action feedback](ACTION_FEEDBACK.md). + +The notice audit found no other timer removing substantive text in app/component +flows. Errors and recovery prompts have explicit controls; component inspectors +have close buttons. Network progress, polling deadlines and permission-expiry +countdowns keep their existing behavior. + +## Verification + +Browser QA uses dedicated rooms and a temporary local transport that delays the +first response and can lose one successful create response. This verifies actual +rendered progress, durable recovery instructions, and explicit same-request retry +without introducing production timing changes. Existing room-entry regressions +cover retry identity, storage failure, ambiguous responses and no duplicate +creates; notice regressions cover retention, continuation, summaries and mute. + +Record browser, source-bound checks and deployed acceptance separately. Test +normal create/join and notice controls on the deployed revision; a local delayed +transport test does not establish production behavior. Preserve existing games. diff --git a/lib/action-notice-queue.ts b/lib/action-notice-queue.ts index b0670cc7..9a236239 100644 --- a/lib/action-notice-queue.ts +++ b/lib/action-notice-queue.ts @@ -11,6 +11,36 @@ export type QueuedActionNotice = AutomaticActionEvent & { count: number }; export type ActionNoticeCursor = { seenSeq: number; pending: QueuedActionNotice[] }; +export type ActionNoticePresentation = ActionNoticeCursor & { active: QueuedActionNotice | null }; + +type ActionNoticeUpdate = + | { type: 'events'; events: readonly AutomaticActionEvent[]; enabled: boolean; visible: boolean } + | { type: 'continue' } + | { type: 'hide' } + | { type: 'clear' }; + +/** Existing history establishes a silent baseline, including after refresh. */ +export function initialActionNotices(events: readonly AutomaticActionEvent[]): ActionNoticePresentation { + return { seenSeq: events.reduce((last, event) => Math.max(last, event.seq), -1), pending: [], active: null }; +} + +/** Local presentation only: reading or dismissing a notice never advances the game. */ +export function updateActionNotices(state: ActionNoticePresentation, update: ActionNoticeUpdate): ActionNoticePresentation { + if (update.type === 'hide') return { ...state, pending: [] }; + if (update.type === 'clear') return { ...state, active: null, pending: [] }; + if (update.type === 'continue') return { ...state, active: state.pending[0] ?? null, pending: state.pending.slice(1) }; + + const active = update.enabled ? state.active : null; + const cursor = advanceActionNotices(state, update.events, { + active: !!active, + show: update.enabled && update.visible, + }); + // New updates never replace text that the player has not dismissed. + return active + ? { ...cursor, active } + : { ...cursor, active: cursor.pending[0] ?? null, pending: cursor.pending.slice(1) }; +} + /** Consume every observed sequence, even when presentation is disabled. */ export function advanceActionNotices( cursor: ActionNoticeCursor, @@ -30,7 +60,7 @@ export function advanceActionNotices( }; } -/** Keep at most three 1.5-second notices, including the one already on screen. */ +/** Keep at most three notices, including the one awaiting local dismissal. */ export function appendActionNotices( pending: readonly QueuedActionNotice[], incoming: readonly AutomaticActionEvent[], diff --git a/tests/action-notice-queue.test.tsx b/tests/action-notice-queue.test.tsx index f4c55636..222546fc 100644 --- a/tests/action-notice-queue.test.tsx +++ b/tests/action-notice-queue.test.tsx @@ -1,8 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { Children, isValidElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { AutomaticActionNotice } from '../components/automatic-action-notice'; -import { advanceActionNotices, appendActionNotices, type AutomaticActionEvent } from '../lib/action-notice-queue'; +import { ActionNoticeCard, AutomaticActionNotice } from '../components/automatic-action-notice'; +import { Button } from '../components/ui/button'; +import { advanceActionNotices, appendActionNotices, initialActionNotices, updateActionNotices, type AutomaticActionEvent } from '../lib/action-notice-queue'; const events = (first: number, count: number): AutomaticActionEvent[] => Array.from({ length: count }, (_, index) => ({ @@ -17,7 +19,7 @@ void test('ordinary notices keep their public action and house without mutating assert.deepEqual(appendActionNotices([], [], false), []); }); -void test('a full log batch becomes three brief notices while accounting for every action', () => { +void test('a full log batch becomes three notices while accounting for every action', () => { const incoming = events(1, 250); const queue = appendActionNotices([], incoming, false); assert.equal(queue.length, 3); @@ -28,6 +30,66 @@ void test('a full log batch becomes three brief notices while accounting for eve assert.equal(queue[0].faction, incoming[247].faction); }); +void test('unread action text survives new bursts and each Continue advances only the local queue', () => { + const initial = initialActionNotices(events(1, 4)); + assert.deepEqual(initial, { seenSeq: 4, pending: [], active: null }); + const shown = updateActionNotices(initial, { type: 'events', events: events(1, 7), enabled: true, visible: true }); + assert.equal(shown.active?.seq, 5); + const before = structuredClone(shown); + const burst = updateActionNotices(shown, { type: 'events', events: events(8, 250), enabled: true, visible: true }); + assert.deepEqual(burst.active, shown.active); + assert.equal(burst.pending.length, 2); + assert.equal((burst.active?.count ?? 0) + burst.pending.reduce((sum, notice) => sum + notice.count, 0), 253); + assert.deepEqual(shown, before); + const first = updateActionNotices(burst, { type: 'continue' }); + assert.deepEqual(first.active, burst.pending[0]); + assert.deepEqual(first.pending, burst.pending.slice(1)); + const second = updateActionNotices(first, { type: 'continue' }); + assert.deepEqual(second.active, burst.pending[1]); + const finished = updateActionNotices(second, { type: 'continue' }); + assert.deepEqual(finished, { seenSeq: 257, pending: [], active: null }); + assert.deepEqual(updateActionNotices(finished, { type: 'events', events: events(1, 257), enabled: true, visible: true }), finished); +}); + +void test('backgrounding retains the unread notice while discarding pending and hidden events without replay', () => { + const shown = updateActionNotices(initialActionNotices([]), { type: 'events', events: events(1, 3), enabled: true, visible: true }); + const hidden = updateActionNotices(shown, { type: 'hide' }); + assert.deepEqual(hidden.active, shown.active); + assert.deepEqual(hidden.pending, []); + const updated = updateActionNotices(hidden, { type: 'events', events: events(1, 20), enabled: true, visible: false }); + assert.deepEqual(updated, { seenSeq: 20, pending: [], active: shown.active }); + const returned = updateActionNotices(updated, { type: 'events', events: events(1, 20), enabled: true, visible: true }); + assert.deepEqual(returned, updated); + const dismissed = updateActionNotices(returned, { type: 'continue' }); + assert.deepEqual(dismissed, { seenSeq: 20, pending: [], active: null }); +}); + +void test('turning notices off clears unread content and consumes muted updates without replay', () => { + const shown = updateActionNotices(initialActionNotices([]), { type: 'events', events: events(1, 3), enabled: true, visible: true }); + assert.deepEqual(updateActionNotices(shown, { type: 'clear' }), { seenSeq: 3, pending: [], active: null }); + const muted = updateActionNotices(shown, { type: 'events', events: events(1, 20), enabled: false, visible: true }); + assert.deepEqual(muted, { seenSeq: 20, pending: [], active: null }); + assert.deepEqual(updateActionNotices(muted, { type: 'events', events: events(1, 20), enabled: true, visible: true }), muted); +}); + +void test('an action card exposes a readable local Continue button without modal or automatic focus behavior', () => { + let continued = 0; + const notice = { ...events(1, 1)[0], name: 'Shipment completed', count: 8 }; + const card = ActionNoticeCard({ notice, onContinue: () => { continued++; } }); + const markup = renderToStaticMarkup(card); + assert.match(markup, /Atreides/); + assert.match(markup, /Shipment completed/); + assert.match(markup, /7 earlier actions.*table chronicle/); + assert.match(markup, /pointer-events-auto/); + assert.match(markup, /text-base/); + assert.match(markup, /]*type="button"[^>]*>Continue<\/button>/); + assert.doesNotMatch(markup, /text-xs|autofocus|aria-modal|role="dialog"/); + const control = Children.toArray(card.props.children).filter(isValidElement).find(child => child.type === Button); + assert.ok(control); + (control.props as { onClick: () => void }).onClick(); + assert.equal(continued, 1); +}); + void test('incoming bursts preserve the visible notice and merge existing summaries without losing counts', () => { const queue = appendActionNotices([], events(1, 12), false); const active = queue[0], before = structuredClone(queue); diff --git a/tools/verify-container.mjs b/tools/verify-container.mjs index 3611e17e..99ab491a 100644 --- a/tools/verify-container.mjs +++ b/tools/verify-container.mjs @@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join as joinPath } from 'node:path'; const image = process.argv[2]; if (!image) throw new Error('Usage: node tools/verify-container.mjs IMAGE'); @@ -13,7 +13,7 @@ const name = `dune-verify-${randomUUID()}`; const volume = `${name}-data`; const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim(); let base; -const adminFiles = mkdtempSync(join(tmpdir(), 'dune-admin-container-')); +const adminFiles = mkdtempSync(joinPath(tmpdir(), 'dune-admin-container-')); let adminCookie; async function ready() { for (let attempt = 0; attempt < 90; attempt++) { @@ -36,12 +36,12 @@ try { docker('volume', 'create', volume); start(); await ready(); - execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'viewer', '--out', join(adminFiles, 'key')]); + execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'viewer', '--out', joinPath(adminFiles, 'key')]); execFileSync('docker', ['exec', '-i', name, 'node', '-e', "require('node:fs').writeFileSync('/tmp/dune-admin-provision.sql', require('node:fs').readFileSync(0), {mode:0o600})"], { - input: readFileSync(join(adminFiles, 'key/provision.sql')), // Container USER owns this private file. + input: readFileSync(joinPath(adminFiles, 'key/provision.sql')), // Container USER owns this private file. }); docker('exec', name, 'node', 'node_modules/wrangler/bin/wrangler.js', 'd1', 'execute', 'DB', '--local', '--config', 'tools/wrangler.local.json', '--persist-to', '/data', '--file', '/tmp/dune-admin-provision.sql'); - const adminKey = readFileSync(join(adminFiles, 'key/access-key.txt'), 'utf8').trim(); + const adminKey = readFileSync(joinPath(adminFiles, 'key/access-key.txt'), 'utf8').trim(); const adminLogin = await fetch(`${base}/api/admin/session`, { method: 'POST', headers: { 'content-type': 'application/json', origin: base }, body: JSON.stringify({ action: 'login', key: adminKey }), @@ -79,7 +79,7 @@ try { start(); await ready(); await restored(); - execFileSync('node', ['tools/verify-admin.mjs', '--url', base, '--key-file', join(adminFiles, 'key/access-key.txt'), '--qa-account', adminKey.split('.')[1], '--out', join(adminFiles, 'http')], { stdio: 'inherit' }); + execFileSync('node', ['tools/verify-admin.mjs', '--url', base, '--key-file', joinPath(adminFiles, 'key/access-key.txt'), '--qa-account', adminKey.split('.')[1], '--out', joinPath(adminFiles, 'http')], { stdio: 'inherit' }); adminCookie = undefined; // The acceptance test intentionally signs out all sessions. execFileSync('npm', ['run', 'test:integration'], { stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base },