Keep popup instructions readable until continued

Show concise progress during first room create/join; reserve persistent recovery instructions for saved/uncertain attempts. Retain completed-action text until a local Continue button, without gating rules or AI. Preserve unread text across backgrounding, bounded summaries, mute and private-safe feedback. Fix the container QA helper path-import collision.

Verified: typecheck, lint, 5200 offline tests, 52 HTTP tests and production build. Delayed/lost-response browser checks prove progress, refresh recovery and exact retry without duplicates. Notice stayed for over 47 seconds while the QA game progressed; Continue advances only presentation. Mobile 16px/44px controls verified. Existing games preserved. Container/deployed acceptance follows publication.
This commit is contained in:
Quintine committed 2026-09-22 09:51:55 +10:00
1 parent c2d392e155
commit f29d05620c
10 files changed
+238 -115

No files matched your search

+4
View File
@@ -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.
+17
View File
@@ -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() {
</section>
</main>
);
// 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 (
<main className="table-shell" aria-busy="true">
<header className="masthead">
<div className="wordmark">DUNE<span>ARRAKIS TABLE</span></div>
</header>
<section className="notice">
<h1>{entryAttempt.kind === 'create' ? 'Creating your room…' : 'Joining the room…'}</h1>
<output aria-live="polite">Please wait.</output>
</section>
</main>
);
if (entryAttempt || entryProblem)
return (
<main className="table-shell" aria-busy={busy}>
+32 -94
View File
@@ -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<QueuedActionNotice[]>([]);
const active = useRef<QueuedActionNotice | null>(null);
const [notice, setNotice] = useState<
(QueuedActionNotice & { fading: boolean }) | null
>(null);
const [completed, setCompleted] = useState(0);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
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 && (
<span
className={`block rounded-xl border border-[#807456] border-l-4 bg-[#141814]/95 px-4 py-3 text-[#f5ecda] shadow-xl transition-opacity duration-250 motion-reduce:opacity-100 motion-reduce:transition-none ${notice.fading ? 'opacity-0' : 'opacity-100'}`}
style={{ borderLeftColor: house?.color ?? '#d4b77b' }}
>
<span
className="block text-xs font-semibold tracking-wider uppercase"
style={{ color: house?.color ?? '#d4b77b' }}
>
{house?.name ?? 'Automatic action'}
</span>
<span className="mt-1 block break-words text-sm font-medium leading-5">
{notice.name}
</span>
{notice.count > 1 && <span className="mt-1 block text-sm leading-5 text-[#d6cdb8]">
{notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
</span>}
</span>
{enabled && presentation.active && (
<ActionNoticeCard notice={presentation.active} onContinue={() => updatePresentation({ type: 'continue' })} />
)}
</output>
</>
);
}
/** 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 <span
className="pointer-events-auto block max-h-[calc(100dvh-2rem)] overflow-y-auto rounded-xl border border-[#807456] border-l-4 bg-[#141814]/95 px-4 py-3 text-[#f5ecda] shadow-xl"
style={{ borderLeftColor: house?.color ?? '#d4b77b' }}
>
<span className="block text-sm font-semibold leading-5 tracking-wider uppercase" style={{ color: house?.color ?? '#d4b77b' }}>
{house?.name ?? 'Automatic action'}
</span>
<span className="mt-1 block break-words text-base font-medium leading-6">{notice.name}</span>
{notice.count > 1 && <span className="mt-1 block text-sm leading-5 text-[#d6cdb8]">
{notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
</span>}
<Button type="button" variant="outline" className="mt-3 min-h-11 max-w-full whitespace-normal" style={{ fontSize: '1rem' }} onClick={onContinue}>
Continue
</Button>
</span>;
}
+23 -11
View File
@@ -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
+10
View File
@@ -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.
+10
View File
@@ -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
+40
View File
@@ -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.
+31 -1
View File
@@ -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[],
+65 -3
View File
@@ -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, /<button[^>]*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);
+6 -6
View File
@@ -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 },