From a8a86852c0e06e518d816ea35400ef9878a365f3 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Mon, 17 Aug 2026 13:49:39 -0700 Subject: [PATCH] Convert Twitch controls to video overlay --- README.md | 4 + apps/backend/src/server.ts | 3 +- apps/stream-view/public/app.js | 4 +- apps/stream-view/public/index.html | 8 +- apps/stream-view/public/styles.css | 113 ++++++++++++++++++++++++++++- docs/testing.md | 31 ++++++++ docs/twitch-setup.md | 33 +++++++-- tests/integration/api.test.ts | 2 +- 8 files changed, 182 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d9aeaa7..2682f63 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ npm run dev Open `http://localhost:3000`. Expand **Local viewer login**, then choose **Spawn & bind** to exercise the complete local controller loop without Twitch. +The production Twitch viewer is the transparent Video Overlay at +`http://localhost:3000/extension`; use +`http://localhost:3000/extension?dev=1&debug=1` to exercise its shared controls +and view alignment boundaries locally. Quality gates: diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index a51d7d0..0827cdf 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -68,7 +68,8 @@ export const server=createServer(async(req,res)=>{ } if(req.method==='POST'&&url.pathname==='/api/dev/spawn'&&!config.twitchEnabled){const msg=await twitch.normalizeSpawn(await body(req));if(!msg)return json(res,400,{error:'INVALID_SPAWN'});const result=game.spawn(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)} if(req.method==='POST'&&url.pathname==='/api/dev/redemption'&&!config.twitchEnabled){const msg=await twitch.normalizeRedemption(await body(req));if(!msg)return json(res,400,{error:'INVALID_REDEMPTION'});const result=game.resurrect(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)} - if(req.method==='GET'&&(url.pathname==='/'||url.pathname==='/extension')){const html=await readFile(join(publicDir,'index.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(html)} + if(req.method==='GET'&&url.pathname==='/'){const page=await readFile(join(publicDir,'index.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(page)} + if(req.method==='GET'&&url.pathname==='/extension'){const page=(await readFile(join(publicDir,'index.html'),'utf8')).replace('','');res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(page)} if(req.method==='GET'&&url.pathname==='/privacy.html'){const html=await readFile(join(publicDir,'privacy.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(html)} if(req.method==='GET'&&url.pathname==='/app.js'){const js=await readFile(join(publicDir,'app.js'));res.writeHead(200,{'content-type':'text/javascript; charset=utf-8','cache-control':'no-store'});return res.end(js)} if(req.method==='GET'&&url.pathname==='/styles.css'){const css=await readFile(join(publicDir,'styles.css'));res.writeHead(200,{'content-type':'text/css; charset=utf-8','cache-control':'no-store'});return res.end(css)} diff --git a/apps/stream-view/public/app.js b/apps/stream-view/public/app.js index 26aa355..0b8add3 100644 --- a/apps/stream-view/public/app.js +++ b/apps/stream-view/public/app.js @@ -1,5 +1,5 @@ let state=null,session=null,userId=null,lastSequence=0,activeSocket=null,extensionIdentityPending=false -const extensionMode=location.pathname==='/extension';document.body.classList.toggle('extension-mode',extensionMode) +const extensionMode=location.pathname==='/extension',overlayParams=new window.URLSearchParams(location.search),localOverlayHost=['localhost','127.0.0.1','[::1]'].includes(location.hostname),overlayDevMode=extensionMode&&localOverlayHost&&overlayParams.get('dev')==='1';document.documentElement.classList.toggle('extension-mode',extensionMode);document.documentElement.classList.toggle('overlay-debug',extensionMode&&localOverlayHost&&overlayParams.get('debug')==='1');document.documentElement.classList.toggle('overlay-dev',overlayDevMode) const $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s) const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])) function disabledReason(){if(extensionIdentityPending)return 'Share your Twitch identity to enable controls.';if(!userId)return extensionMode?'Waiting for Twitch authorization.':'Broadcast mode — log in locally to test controls.';const p=state?.players.find(x=>x.twitchUserId===userId);if(!p)return extensionMode?'Click Spawn character to join.':'Type !spawn in chat first.';if(p.lifeState==='dead')return 'Your character is dead.';if(state.phase.kind!=='player')return 'Wait for Player Phase.';if(!p.ap)return 'No AP remains this phase.';return ''} @@ -9,7 +9,7 @@ function render(){if(!state)return;$('#floor').textContent=`Floor ${state.floorN const map=$('#map');map.style.gridTemplateColumns=`repeat(${state.floor.width},auto)`;map.innerHTML='' for(let y=0;yp.lifeState==='alive'&&p.position.x===x&&p.position.y===y);const gob=state.goblin.mode!=='dead'&&state.goblin.position.x===x&&state.goblin.position.y===y;if(gob||p){const e=document.createElement('span');e.className=`entity ${gob?'goblin':'player'}`;e.textContent=gob?'◆':'●';e.title=gob?'Goblin':p.displayName;el.append(e)}map.append(el)} $('#log').innerHTML=state.actionLog.slice(-40).map(e=>`
  • #${e.sequence} ${escapeHtml(e.message)}
  • `).join('');$('#log').scrollTop=$('#log').scrollHeight - const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason));const spawnButton=$('#spawnExtension');spawnButton.hidden=!extensionMode;spawnButton.disabled=!session||Boolean(me);spawnButton.textContent=me?'Character spawned':'Spawn character' + const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason));const spawnButton=$('#spawnExtension');spawnButton.hidden=!extensionMode||overlayDevMode;spawnButton.disabled=!session||Boolean(me);spawnButton.textContent=me?'Character spawned':'Spawn character' } async function api(path,options={}){const res=await fetch(path,{...options,headers:{'content-type':'application/json',...(session?{authorization:`Bearer ${session}`}:{})}});const data=await res.json();if(!res.ok)throw new Error(data.message||data.error||data.reason||'Request failed');return data} async function authorizeExtension(token){const auth=await api('/api/extension/session',{method:'POST',body:JSON.stringify({token})});session=auth.token;userId=auth.twitchUserId;state=auth.state;if(activeSocket?.readyState===WebSocket.OPEN)activeSocket.send(JSON.stringify({type:'authenticate',token:session}));render()} diff --git a/apps/stream-view/public/index.html b/apps/stream-view/public/index.html index b5d16bc..368b5e7 100644 --- a/apps/stream-view/public/index.html +++ b/apps/stream-view/public/index.html @@ -1,10 +1,10 @@ -Twungeon +Twungeon
    -
    + diff --git a/apps/stream-view/public/styles.css b/apps/stream-view/public/styles.css index 8a5654f..bc6beb1 100644 --- a/apps/stream-view/public/styles.css +++ b/apps/stream-view/public/styles.css @@ -1 +1,112 @@ -:root{color-scheme:dark;--ink:#f5ecd8;--muted:#b8ad98;--gold:#e8b04b;--red:#db604c;--panel:#171a1d;--line:#34383b;--floor:#3a3832;--wall:#111315}*{box-sizing:border-box}body{margin:0;background:#0d0f10;color:var(--ink);font:15px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;background-image:radial-gradient(#252017 1px,transparent 1px);background-size:20px 20px}.shell{height:100vh;min-height:650px;padding:18px;display:grid;grid-template-columns:minmax(260px,28%) 1fr;grid-template-rows:minmax(0,1fr) 31%;gap:14px}.panel{background:linear-gradient(145deg,#191c1f,#111315);border:1px solid var(--line);box-shadow:0 18px 60px #0008,inset 0 1px #ffffff0d;border-radius:5px}.status{padding:22px;display:flex;flex-direction:column;gap:20px;overflow:auto}.game{padding:20px;display:flex;flex-direction:column;min-width:0;position:relative}.log{grid-column:1/-1;padding:15px 20px;overflow:hidden;display:flex;flex-direction:column}h1,h2,p{margin:0}h1{font:800 31px/1 system-ui;letter-spacing:.08em}h1 span{color:var(--gold)}h2{font:700 18px/1.2 system-ui}.eyebrow{text-transform:uppercase;color:var(--gold);font-size:11px;letter-spacing:.16em;margin-bottom:6px}.game-head,.log-head{display:flex;align-items:center;justify-content:space-between}.phase{padding:6px 10px;border:1px solid var(--gold);color:var(--gold);border-radius:2px;text-transform:uppercase;font-size:12px}#banner{position:absolute;z-index:3;inset:45% auto auto 50%;transform:translate(-50%,-50%);padding:16px 22px;background:#111e;border:1px solid var(--gold);box-shadow:0 0 40px #000;text-align:center;color:var(--gold);font-weight:bold;white-space:nowrap}#map{flex:1;display:grid;align-content:center;justify-content:center;margin:12px 0;min-height:0}.tile{width:min(3.6vw,42px);aspect-ratio:1;border:1px solid #222;display:grid;place-items:center;position:relative;font-size:min(1.6vw,18px)}.tile.wall{background:var(--wall);box-shadow:inset 0 0 0 2px #1b1f21}.tile.floor{background:var(--floor)}.tile.exit{background:#68491b;color:#ffd77e}.entity{position:absolute;inset:12%;border-radius:50%;display:grid;place-items:center;font-weight:bold;text-shadow:0 1px 2px #000}.entity.player{background:#4f8cc9;border:2px solid #b9deff}.entity.goblin{background:var(--red);border:2px solid #ffb2a6;border-radius:20%}.legend{display:flex;gap:18px;justify-content:center;color:var(--muted);font-size:12px}#status{display:grid;grid-template-columns:1fr 1fr;gap:8px}.stat{border:1px solid var(--line);padding:9px}.stat b{display:block;color:var(--gold);font-size:18px}.dpad{display:grid;grid-template-columns:repeat(3,42px);gap:5px;margin:10px 0}.dpad button:nth-child(1){grid-column:2}.dpad button:nth-child(2){grid-column:1}.actions{display:flex;gap:5px;flex-wrap:wrap}button,input{font:inherit}button{background:#292d2f;color:var(--ink);border:1px solid #53595c;padding:8px 10px;cursor:pointer}button:hover:not(:disabled){border-color:var(--gold);color:var(--gold)}button:disabled{opacity:.35;cursor:not-allowed}#disabled{color:var(--muted);font-size:12px;margin-top:8px}details{margin-top:auto;color:var(--muted)}details label{display:block;margin:8px 0}input{display:block;width:100%;background:#0d0f10;border:1px solid var(--line);color:var(--ink);padding:6px}#log{margin:8px 0 0;padding:0;list-style:none;overflow:auto;display:flex;flex-direction:column;gap:3px}#log li{padding:4px 8px;border-left:2px solid var(--line);color:var(--muted)}#log li:last-child{color:var(--ink);border-color:var(--gold)}#connection{font-size:12px;color:var(--muted)}@media(max-width:760px){.shell{height:auto;grid-template-columns:1fr;grid-template-rows:auto minmax(500px,70vh) 360px}.log{grid-column:1}.tile{width:min(5vw,28px)}#banner{white-space:normal;width:75%}} +:root { + --ink: #f5ecd8; + --muted: #b8ad98; + --gold: #e8b04b; + --red: #db604c; + --panel: #171a1d; + --line: #34383b; + --floor: #3a3832; + --wall: #111315; + + /* Normalized to the matching control panel in the 16:9 broadcast layout. */ + --controls-left: 0.95%; + --controls-top: 1.7%; + --controls-width: 27.1%; + --controls-height: 65.8%; + --controls-padding: clamp(8px, 1.15vw, 22px); +} + +* { box-sizing: border-box; } +html:not(.extension-mode) { color-scheme: dark; } +html.extension-mode { color-scheme: normal; } +body { margin: 0; background: #0d0f10; color: var(--ink); font: 15px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; background-image: radial-gradient(#252017 1px, transparent 1px); background-size: 20px 20px; } +.shell { height: 100vh; min-height: 650px; padding: 18px; display: grid; grid-template-columns: minmax(260px, 28%) 1fr; grid-template-rows: minmax(0, 1fr) 31%; gap: 14px; } +.panel { background: linear-gradient(145deg, #191c1f, #111315); border: 1px solid var(--line); box-shadow: 0 18px 60px #0008, inset 0 1px #ffffff0d; border-radius: 5px; } +.status { padding: 22px; display: flex; flex-direction: column; gap: 20px; overflow: auto; } +.game { padding: 20px; display: flex; flex-direction: column; min-width: 0; position: relative; } +.log { grid-column: 1/-1; padding: 15px 20px; overflow: hidden; display: flex; flex-direction: column; } +h1, h2, p { margin: 0; } +h1 { font: 800 31px/1 system-ui; letter-spacing: .08em; } +h1 span { color: var(--gold); } +h2 { font: 700 18px/1.2 system-ui; } +.eyebrow { text-transform: uppercase; color: var(--gold); font-size: 11px; letter-spacing: .16em; margin-bottom: 6px; } +.game-head, .log-head { display: flex; align-items: center; justify-content: space-between; } +.phase { padding: 6px 10px; border: 1px solid var(--gold); color: var(--gold); border-radius: 2px; text-transform: uppercase; font-size: 12px; } +#banner { position: absolute; z-index: 3; inset: 45% auto auto 50%; transform: translate(-50%, -50%); padding: 16px 22px; background: #111e; border: 1px solid var(--gold); box-shadow: 0 0 40px #000; text-align: center; color: var(--gold); font-weight: bold; white-space: nowrap; } +#map { flex: 1; display: grid; align-content: center; justify-content: center; margin: 12px 0; min-height: 0; } +.tile { width: min(3.6vw, 42px); aspect-ratio: 1; border: 1px solid #222; display: grid; place-items: center; position: relative; font-size: min(1.6vw, 18px); } +.tile.wall { background: var(--wall); box-shadow: inset 0 0 0 2px #1b1f21; } +.tile.floor { background: var(--floor); } +.tile.exit { background: #68491b; color: #ffd77e; } +.entity { position: absolute; inset: 12%; border-radius: 50%; display: grid; place-items: center; font-weight: bold; text-shadow: 0 1px 2px #000; } +.entity.player { background: #4f8cc9; border: 2px solid #b9deff; } +.entity.goblin { background: var(--red); border: 2px solid #ffb2a6; border-radius: 20%; } +.legend { display: flex; gap: 18px; justify-content: center; color: var(--muted); font-size: 12px; } +#status { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.stat { border: 1px solid var(--line); padding: 9px; } +.stat b { display: block; color: var(--gold); font-size: 18px; } +.dpad { display: grid; grid-template-columns: repeat(3, 42px); gap: 5px; margin: 10px 0; } +.dpad button:nth-child(1) { grid-column: 2; } +.dpad button:nth-child(2) { grid-column: 1; } +.actions { display: flex; gap: 5px; flex-wrap: wrap; } +button, input { font: inherit; } +button { background: #292d2f; color: var(--ink); border: 1px solid #53595c; padding: 8px 10px; cursor: pointer; } +button:hover:not(:disabled) { border-color: var(--gold); color: var(--gold); } +button:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; } +button:disabled { opacity: .35; cursor: not-allowed; } +#disabled { color: var(--muted); font-size: 12px; margin-top: 8px; } +details { margin-top: auto; color: var(--muted); } +details label { display: block; margin: 8px 0; } +input { display: block; width: 100%; background: #0d0f10; border: 1px solid var(--line); color: var(--ink); padding: 6px; } +#log { margin: 8px 0 0; padding: 0; list-style: none; overflow: auto; display: flex; flex-direction: column; gap: 3px; } +#log li { padding: 4px 8px; border-left: 2px solid var(--line); color: var(--muted); } +#log li:last-child { color: var(--ink); border-color: var(--gold); } +#connection { font-size: 12px; color: var(--muted); } + +/* Twitch Video Overlay mode: the stream supplies every noninteractive surface. */ +html.extension-mode, html.extension-mode body { width: 100%; height: 100%; overflow: hidden; background: transparent; background-image: none; pointer-events: none; } +html.extension-mode .shell { position: fixed; inset: 0; display: block; min-height: 0; padding: 0; pointer-events: none; } +html.extension-mode .game, html.extension-mode .log, html.extension-mode .branding, html.extension-mode #status, html.extension-mode details { display: none; } +html.extension-mode .status { + position: absolute; + left: var(--controls-left); + top: var(--controls-top); + width: var(--controls-width); + height: var(--controls-height); + padding: var(--controls-padding); + display: grid; + place-items: center; + overflow: hidden; + container: overlay-controls / size; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + pointer-events: none; +} +html.extension-mode #controller { width: min(100%, 18rem); pointer-events: none; } +html.extension-mode #controller h2 { font-size: clamp(11px, 5cqw, 18px); margin-bottom: clamp(4px, 2cqh, 12px); text-shadow: 0 1px 3px #000; } +html.extension-mode #controller button { pointer-events: auto; touch-action: manipulation; } +html.extension-mode .dpad { grid-template-columns: repeat(3, minmax(28px, 1fr)); gap: clamp(3px, 1.8cqw, 7px); margin: 0 0 clamp(6px, 2.5cqh, 14px); } +html.extension-mode .dpad button { aspect-ratio: 1; padding: 0; font-size: clamp(13px, 6cqw, 24px); } +html.extension-mode .actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(3px, 1.8cqw, 7px); } +html.extension-mode .actions button { padding: clamp(5px, 1.7cqh, 10px) clamp(2px, 1cqw, 8px); font-size: clamp(9px, 4.3cqw, 15px); } +html.extension-mode #spawnExtension, html.extension-mode #shareIdentity { width: 100%; margin-bottom: clamp(5px, 2cqh, 12px); font-size: clamp(9px, 4.3cqw, 15px); } +html.extension-mode #disabled { min-height: 2.8em; margin-top: clamp(5px, 2cqh, 10px); color: var(--ink); font-size: clamp(9px, 3.7cqw, 13px); text-shadow: 0 1px 3px #000, 0 0 8px #000; } + +/* Opt-in local diagnostics. These classes are never enabled by production URLs. */ +html.extension-mode.overlay-debug .status { outline: 2px dashed #00e5ff; outline-offset: -2px; background: #00e5ff0d; } +html.extension-mode.overlay-debug #controller { outline: 1px dashed #ff4fd8; } +html.extension-mode.overlay-dev details { display: block; width: 100%; margin-top: clamp(8px, 3cqh, 16px); pointer-events: auto; font-size: clamp(9px, 3.7cqw, 13px); } +html.extension-mode.overlay-dev .status { place-items: start center; overflow: auto; } + +@media (max-width: 760px) { + html:not(.extension-mode) .shell { height: auto; grid-template-columns: 1fr; grid-template-rows: auto minmax(500px, 70vh) 360px; } + html:not(.extension-mode) .log { grid-column: 1; } + html:not(.extension-mode) .tile { width: min(5vw, 28px); } + html:not(.extension-mode) #banner { white-space: normal; width: 75%; } +} + +@media (max-aspect-ratio: 4/3) { + html.extension-mode { --controls-width: 31%; --controls-height: 62%; } +} diff --git a/docs/testing.md b/docs/testing.md index 4bf04fb..b1b5d56 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -36,6 +36,37 @@ Twitch identities; they do not require credentials. The local-only endpoints `/api/dev/spawn` and `/api/dev/redemption` exist only while `TWITCH_ENABLED` is false. +## Local video-overlay verification + +The production viewer path is `/extension`. It is safe to open directly, but it +waits for Twitch authorization when it is not inside Twitch. While running the +synthetic local backend, open: + +```text +http://localhost:3000/extension?dev=1&debug=1 +``` + +`dev=1` exposes the existing **Local viewer login** inside the overlay so the +same controller and API flow can be exercised without Twitch. `debug=1` draws a +cyan control-region boundary and a pink controller boundary. Both switches are +accepted only on `localhost`, `127.0.0.1`, or `[::1]`, so the diagnostics cannot +be enabled on the deployed Extension origin. + +Verify at 1920×1080, 1280×720, and a narrower or 4:3 viewport: + +- the document and empty overlay canvas remain transparent; +- controls stay within the upper-left debug boundary and remain readable; +- the dungeon, log, branding, and status tiles are absent from `/extension`; +- only buttons (and the local-only login form) accept pointer events; +- Up, Down, Left, Right, Attack, Heal, and Pass reach the same backend routes; +- missing character, dead character, non-player phase, and zero-AP states still + disable the controls; and +- loading `/` still presents the full local game and controller. + +Remove the query string for the production-shaped local view. Twitch-hosted +testing still must cover identity sharing, player ownership, theater mode, +fullscreen, embeds, ads/pauses, and Twitch player-control safe zones. + ## Live-channel campaign After completing `docs/twitch-setup.md`, use two follower accounts and one diff --git a/docs/twitch-setup.md b/docs/twitch-setup.md index 71067f2..bc3fcb0 100644 --- a/docs/twitch-setup.md +++ b/docs/twitch-setup.md @@ -70,18 +70,37 @@ token file inside the repository or a web-served directory. ## Extension configuration +Twungeon's production viewer is a **Video Overlay Extension**. Twitch's current +Extension Manager labels this asset type **Video - Fullscreen** and its path +field **Video - Fullscreen View Path**. This is a manual dashboard setting; the +repository has no manifest that can change an Extension version in Twitch. + 1. Create a Twitch Extension separately from the confidential OAuth - application. In the Extension Manager, use a testing base URI ending in - `/`, select a video component, and set its viewer path to `extension`. -2. Enable **Request Identity Link**. The viewer must click **Share Twitch - identity** in the component; the Extension Helper then invokes + application, or create a new test version of the existing Twungeon + Extension. +2. On **Asset Hosting**, set **Testing Base URI** to the public HTTPS Twungeon + origin with a trailing `/`. +3. Under **Type of Extension**, enable **Video - Fullscreen** (the video-overlay + placement) and set **Video - Fullscreen View Path** to `extension`. Do not + use **Video - Component** for the production viewer. If a component entry is + retained temporarily for development, it may point at the same shared page, + but the overlay placement is the supported viewer experience. +4. On **Capabilities**, enable **Request Identity Link**. The viewer must click + **Share Twitch identity** in the overlay; the Extension Helper then invokes `requestIdShare()` from that user gesture and supplies a new JWT through `onAuthorized`. -3. A JWT without `user_id`, with the wrong +5. Put the version in Local Test or Hosted Test and activate it in the channel's + video-overlay slot. Changing an already released version may require a new + Extension version and Twitch review; the repository cannot submit or + activate that version automatically. +6. Test the overlay while live in normal, theater, fullscreen, and a narrow + player. The video player and stream supply the game frame; `/extension` + should show only the upper-left controls on a transparent canvas. +7. A JWT without `user_id`, with the wrong `channel_id`, an expired signature, or an `external` role is rejected. -4. Keep the Extension secret only in the backend environment. It must never be +8. Keep the Extension secret only in the backend environment. It must never be included in the UI bundle or URL. -5. Start with `npm run build && npm start`. Confirm `/health` returns `ready: +9. Start with `npm run build && npm start`. Confirm `/health` returns `ready: true` and `twitchMode: "configured"` after chat connects. The Extension JWT is exchanged for a random 15-minute Twungeon session. A diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index fda1f46..eedb053 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -9,7 +9,7 @@ async function post(path:string,value:unknown,token?:string){return fetch(`${bas describe('backend boundary',()=>{ it('AT-001 exposes a secret-free readiness response',async()=>{const data=await fetch(`${base}/health`).then(r=>r.json());expect(data).toMatchObject({status:'ok',ready:true,twitchMode:'synthetic'});expect(JSON.stringify(data)).not.toMatch(/secret|token/i)}) it('reports OAuth readiness without exposing credentials',async()=>{const data=await fetch(`${base}/oauth/status`).then(r=>r.json());expect(data).toMatchObject({configured:false,authorized:false});expect(JSON.stringify(data)).not.toMatch(/clientId|clientSecret|accessToken|refreshToken/)}) - it('serves a compact uncached Twitch identity-sharing controller',async()=>{const [pageResponse,scriptResponse]=await Promise.all([fetch(`${base}/extension`),fetch(`${base}/app.js?v=20260817-spawn`)]),[html,script]=await Promise.all([pageResponse.text(),scriptResponse.text()]);expect(pageResponse.headers.get('cache-control')).toBe('no-store');expect(scriptResponse.headers.get('cache-control')).toBe('no-store');expect(html).toContain('/app.js?v=20260817-spawn');expect(html).toContain('id="spawnExtension"');expect(html).toContain('id="shareIdentity"');expect(html).toContain('body.extension-mode .game');expect(script).toContain("location.pathname==='/extension'");expect(script).toContain('/api/extension/spawn');expect(script).toContain('viewer?.isLinked');expect(script).toContain('actions.requestIdShare()')}) + it('serves a transparent responsive Twitch overlay controller',async()=>{const [pageResponse,rootResponse,scriptResponse,styleResponse]=await Promise.all([fetch(`${base}/extension`),fetch(`${base}/`),fetch(`${base}/app.js?v=20260817-overlay`),fetch(`${base}/styles.css`)]),[html,rootHtml,script,styles]=await Promise.all([pageResponse.text(),rootResponse.text(),scriptResponse.text(),styleResponse.text()]);expect(pageResponse.headers.get('cache-control')).toBe('no-store');expect(scriptResponse.headers.get('cache-control')).toBe('no-store');expect(styleResponse.headers.get('cache-control')).toBe('no-store');expect(html).toContain('');expect(rootHtml).not.toContain('');expect(html).toContain('/app.js?v=20260817-overlay');expect(html).toContain('id="controlRegion"');expect(html).toContain('id="spawnExtension"');expect(html).toContain('id="shareIdentity"');expect(script).toContain("location.pathname==='/extension'");expect(script).toContain("['localhost','127.0.0.1','[::1]']");expect(script).toContain('/api/extension/spawn');expect(script).toContain('/api/commands');expect(script).toContain('viewer?.isLinked');expect(script).toContain('actions.requestIdShare()');expect(styles).toContain('--controls-left:');expect(styles).toContain('html.extension-mode { color-scheme: normal; }');expect(styles).toContain('background: transparent');expect(styles).toContain('pointer-events: none');expect(styles).toContain('#controller button { pointer-events: auto')}) it('serves a public privacy notice for identity linking',async()=>{const response=await fetch(`${base}/privacy.html`),html=await response.text();expect(response.status).toBe(200);expect(html).toContain('Twungeon Privacy Notice');expect(html).toContain('numeric Twitch user ID')}) it('AT-005 establishes a session without creating a character',async()=>{const r=await post('/api/extension/session',{token:'dev:nobody:Nobody'});expect(r.status).toBe(200);const data=await r.json();expect(data.state.viewer).toBeNull()}) it('AT-011 binds the same stable chat and Extension identity',async()=>{const id=`viewer-${Date.now()}`;expect((await post('/api/dev/spawn',{command:'!spawn',externalEventId:`e-${id}`,twitchUserId:id,displayName:'Viewer',followerVerified:true})).status).toBe(200);const auth=await (await post('/api/extension/session',{token:`dev:${id}:Viewer`})).json();expect(auth.twitchUserId).toBe(id);expect(auth.state.viewer.extensionBound).toBe(true);const s=auth.state,phase=s.phase;expect(phase.kind).toBe('player');const command=await post('/api/commands',{requestId:`r-${id}`,runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}},auth.token);expect(command.status).toBe(200)})