mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
Merge pull request #4 from Such-Software/fix/smirk-account-entry-20261021
fix: repair Smirk account login and reconnects
This commit is contained in:
+14
-22
@@ -14,20 +14,23 @@
|
||||
<!-- Initialize socket with resume token (for persistent anonymous session) -->
|
||||
<script>
|
||||
(function(){
|
||||
let opts = {};
|
||||
let opts = { autoConnect: false };
|
||||
window._localStorageAvailable = false;
|
||||
try {
|
||||
// Test localStorage availability (fails in private browsing on some browsers)
|
||||
localStorage.setItem('_test', '1');
|
||||
localStorage.removeItem('_test');
|
||||
window._localStorageAvailable = true;
|
||||
const existing = localStorage.getItem('wownerogue_token');
|
||||
if (existing) {
|
||||
// Send the session token in the handshake `auth` payload, NOT the URL
|
||||
// query string. Query strings leak into proxy/access/referer logs;
|
||||
// auth is carried in the connection body.
|
||||
opts.auth = { resumeToken: existing };
|
||||
}
|
||||
// Read the current token for every initial connection and reconnect, rather
|
||||
// than retaining the token that existed when this page first loaded.
|
||||
opts.auth = function(callback) {
|
||||
let resumeToken = null;
|
||||
if (window._localStorageAvailable) {
|
||||
try { resumeToken = localStorage.getItem('wownerogue_token'); } catch(_) {}
|
||||
}
|
||||
// Credentials stay in Socket.IO's handshake auth payload, never the URL.
|
||||
callback(resumeToken ? { resumeToken: resumeToken } : {});
|
||||
};
|
||||
} catch(_) {
|
||||
window._localStorageAvailable = false;
|
||||
console.warn('localStorage unavailable - session will not persist');
|
||||
@@ -169,6 +172,9 @@
|
||||
<button id="animationToggleButton" class="ctrl-btn ctrl-btn--anim" style="display: none;">
|
||||
🎬 DISABLE ANIMATION
|
||||
</button>
|
||||
<section id="smirkAccountSection" class="smirk-account-entry" aria-label="Smirk account" tabindex="-1" style="display:none;">
|
||||
<div id="smirkButtonContainer"></div>
|
||||
</section>
|
||||
<div class="hud-toolbar">
|
||||
<label>
|
||||
<input type="checkbox" id="audioAlertsToggle"> Audio Alerts
|
||||
@@ -302,20 +308,6 @@
|
||||
<p class="modal-description">Enter your Wownero (WOW) or Monero (XMR) address below. This will be used for payouts when you escape.</p>
|
||||
<p class="modal-description" style="font-size:11px; color:#888; margin-top:8px; padding:6px; background:rgba(255,200,0,0.1); border-radius:4px;">💡 <strong>Session Info:</strong> Your address and credits are tied to a browser session token stored locally. They will persist across page refreshes but will be lost if you clear cookies, use incognito mode, or switch browsers.</p>
|
||||
|
||||
<!-- Smirk Wallet Integration -->
|
||||
<div id="smirkSection" style="margin: 15px 0; padding: 12px; background: rgba(26,26,77,0.5); border: 1px solid #3a3a7d; border-radius: 6px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<span style="font-size: 1.2em;">🔐</span>
|
||||
<strong style="color: #aaf;">Connect with Smirk Wallet</strong>
|
||||
</div>
|
||||
<p style="font-size: 11px; color: #999; margin-bottom: 10px;">Automatically set your payout address by connecting your Smirk browser wallet.</p>
|
||||
<div id="smirkButtonContainer">
|
||||
<!-- Will be populated by smirkAuth.js -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; color: #666; margin: 10px 0; font-size: 12px;">or enter manually</div>
|
||||
|
||||
<form id="addressModalForm">
|
||||
<label for="addressModalInput" class="modal-label">Payout Address</label>
|
||||
<textarea id="addressModalInput" autocomplete="off" spellcheck="false" rows="3" placeholder="Paste your payout address here"></textarea>
|
||||
|
||||
@@ -318,7 +318,7 @@ const SmirkAuth = {
|
||||
* Create and return the Smirk login button element
|
||||
*/
|
||||
_createButton() {
|
||||
const btn = $('<button id="smirkLoginBtn" class="menu-button">')
|
||||
const btn = $('<button id="smirkLoginBtn" type="button" class="menu-button">')
|
||||
.css({
|
||||
'background': '#1a1a4d',
|
||||
'color': '#fff',
|
||||
@@ -330,7 +330,7 @@ const SmirkAuth = {
|
||||
'width': '100%',
|
||||
'text-align': 'center'
|
||||
})
|
||||
.text('Connect Smirk Wallet');
|
||||
.text('Connect Smirk');
|
||||
|
||||
return btn;
|
||||
},
|
||||
@@ -363,7 +363,7 @@ const SmirkAuth = {
|
||||
default: // 'ready'
|
||||
btn.prop('disabled', false)
|
||||
.css({ 'background': '#1a1a4d', 'border-color': '#3a3a7d', 'cursor': 'pointer' })
|
||||
.text('Connect Smirk Wallet');
|
||||
.text('Connect Smirk');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ const SocketHandlers = {
|
||||
|
||||
_directModeEnabled: false,
|
||||
_directPayoutsEnabled: false,
|
||||
_anyPayoutsEnabled: false,
|
||||
_paymentsEnabled: false,
|
||||
_isSmirkExplicitlyEnabled: function(data) {
|
||||
return !!data && data.smirkEnabled === true;
|
||||
@@ -155,6 +156,8 @@ const SocketHandlers = {
|
||||
// If the low-level socket connected before handlers were registered, emulate onConnect.
|
||||
if (window.socket && window.socket.connected) {
|
||||
this.onConnect();
|
||||
} else if (window.socket && typeof window.socket.connect === 'function') {
|
||||
window.socket.connect();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -182,6 +185,7 @@ const SocketHandlers = {
|
||||
// Connection handlers
|
||||
socket.on('connect', this.onConnect);
|
||||
socket.on('disconnect', function() {
|
||||
SocketHandlers._didConnect = false;
|
||||
SocketHandlers._invalidatePaymentSession();
|
||||
if (typeof CommerceConsent !== 'undefined' && CommerceConsent.clear) {
|
||||
CommerceConsent.clear();
|
||||
@@ -874,6 +878,10 @@ const SocketHandlers = {
|
||||
},
|
||||
|
||||
onAddressPrompt: function(data) {
|
||||
if (!SocketHandlers._anyPayoutsEnabled) {
|
||||
console.warn('Ignoring payout address prompt while payouts are disabled.');
|
||||
return;
|
||||
}
|
||||
if (typeof AddressModal !== 'undefined') {
|
||||
AddressModal.show({
|
||||
existingAddress: data?.existingAddress || null,
|
||||
@@ -916,7 +924,14 @@ const SocketHandlers = {
|
||||
&& data.modes.match.economies.crypto_race);
|
||||
var anyPayouts = SocketHandlers._creditsPayoutsEnabled
|
||||
|| SocketHandlers._directPayoutsEnabled || matchCryptoPayouts;
|
||||
SocketHandlers._anyPayoutsEnabled = anyPayouts;
|
||||
$('#manageAddressButton').toggle(anyPayouts);
|
||||
$('#smirkAccountSection').toggle(SocketHandlers._smirkEnabled);
|
||||
if (SocketHandlers._smirkEnabled && window.location.hash === '#smirkAccountSection'
|
||||
&& document.activeElement === document.body) {
|
||||
document.getElementById('smirkAccountSection')?.focus({ preventScroll: true });
|
||||
}
|
||||
if (!anyPayouts && typeof AddressModal !== 'undefined') AddressModal.hide();
|
||||
if (data.entitlements && typeof SinglePlayerAvatar !== 'undefined') {
|
||||
SinglePlayerAvatar.applyEntitlements(data.entitlements);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
},
|
||||
|
||||
show({ existingAddress = null, message = null } = {}) {
|
||||
if (typeof SocketHandlers === 'undefined' || SocketHandlers._anyPayoutsEnabled !== true) {
|
||||
return;
|
||||
}
|
||||
this.ensureInit();
|
||||
if (!this._elements.overlay.length) {
|
||||
return;
|
||||
|
||||
@@ -204,6 +204,12 @@ body {
|
||||
border-top: 1px solid rgba(255,255,255,0.055);
|
||||
}
|
||||
|
||||
.smirk-account-entry { margin-top: 8px; }
|
||||
.smirk-account-entry :is(button, a):focus-visible {
|
||||
outline: 2px solid #aaf;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
HUD TOOLBAR: secondary "chip" controls (audio toggle + help / history /
|
||||
scores / manage-address). Small, bordered, glow-on-hover.
|
||||
|
||||
+11
-3
@@ -44,10 +44,12 @@
|
||||
linear-gradient(180deg,#111015,var(--bg) 48%); color:var(--text); font-family:PixelOperator,ui-monospace,monospace; }
|
||||
body::before { content:"";position:fixed;inset:0;pointer-events:none;opacity:.14;background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);background-size:24px 24px; }
|
||||
.wrap { position:relative; max-width:980px; margin:0 auto; padding:18px clamp(10px,3vw,26px) 32px; }
|
||||
.tavern-head { display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px; }
|
||||
.tavern-head { display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px;flex-wrap:wrap; }
|
||||
.tavern-nav { display:flex;align-items:center;justify-content:flex-end;gap:6px;flex-wrap:wrap; }
|
||||
h1 { color:#fff;font-size:clamp(24px,4vw,34px);margin:0;letter-spacing:.03em; }
|
||||
.backlink { color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:5px;padding:6px 10px;font-size:13px; }
|
||||
.backlink:hover { color:#fff;border-color:#667386; }
|
||||
.backlink:focus-visible { outline:2px solid var(--cyan);outline-offset:2px; }
|
||||
.muted { color:var(--muted);font-size:13px; }
|
||||
.rk-canvas { background:#050609;display:block;max-width:100%;image-rendering:pixelated; }
|
||||
.panel { background:rgba(21,26,32,.97);border:1px solid var(--line);border-radius:9px;padding:14px;margin-bottom:12px;box-shadow:inset 0 1px rgba(255,255,255,.025); }
|
||||
@@ -99,7 +101,7 @@
|
||||
.touch-pad button { width:44px;height:40px;padding:0;touch-action:manipulation;background:rgba(25,45,31,.94);color:#93f3a2;border-color:#4f9a5c; }
|
||||
.touch-pad [data-dir="up"]{grid-area:up}.touch-pad [data-dir="left"]{grid-area:left}.touch-pad [data-dir="down"]{grid-area:down}.touch-pad [data-dir="right"]{grid-area:right}
|
||||
@media (max-width:700px),(pointer:coarse) { .touch-pad{display:grid} #stage,.spectator-render{height:min(54vh,500px)} .join-copy{display:block}.join-copy .muted{display:block;margin-top:5px}.join-fields{grid-template-columns:auto minmax(0,1fr)}.join-fields #customizeBtn,.join-fields #joinBtn{grid-column:auto / span 1}.join-fields label{grid-column:1}.join-fields #name{grid-column:2} }
|
||||
@media (max-width:430px) { .wrap{padding-inline:8px}.tavern-head .muted{display:none}#modebar{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}.modebtn{white-space:nowrap} }
|
||||
@media (max-width:430px) { .wrap{padding-inline:8px}.tavern-head{align-items:flex-start;flex-direction:column;gap:10px}.tavern-head .muted{display:none}.tavern-nav{justify-content:flex-start}#modebar{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}.modebtn{white-space:nowrap} }
|
||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after{animation:none!important;transition:none!important} }
|
||||
</style>
|
||||
</head>
|
||||
@@ -107,7 +109,11 @@
|
||||
<div class="wrap">
|
||||
<header class="tavern-head">
|
||||
<h1>🔥 The Tavern <span class="muted">social hall</span></h1>
|
||||
<span><a class="backlink" href="/responsible-play">Responsible Play</a> <a class="backlink" href="/">← Lobby</a></span>
|
||||
<nav class="tavern-nav" aria-label="Tavern links">
|
||||
<a id="smirkAccountLink" class="backlink" href="/#smirkAccountSection" hidden>Smirk account</a>
|
||||
<a class="backlink" href="/responsible-play">Responsible Play</a>
|
||||
<a class="backlink" href="/">← Lobby</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<form id="joinPanel" class="panel" autocomplete="off">
|
||||
@@ -466,6 +472,8 @@
|
||||
});
|
||||
socket.on('game_mode_info', function (data) {
|
||||
lastGameModeInfo = data || null;
|
||||
var smirkAccountLink = document.getElementById('smirkAccountLink');
|
||||
if (smirkAccountLink) smirkAccountLink.hidden = !(data && data.smirkEnabled === true);
|
||||
if (data && data.entitlements) applyEntitlements(data.entitlements);
|
||||
if (data && data.cryptoType) cryptoType = data.cryptoType;
|
||||
});
|
||||
|
||||
+13
-7
@@ -10,6 +10,16 @@ const { verifyNip98Event } = require('../utils/nip98');
|
||||
const AUTH_WINDOW_MS = 60 * 1000;
|
||||
const AUTH_RATE_MAX_KEYS = 10000;
|
||||
|
||||
// Shared with the real PostgreSQL lifecycle check so parameter inference is exercised
|
||||
// against the migrated schema, rather than accepted only by query mocks.
|
||||
const challengeInsertQuery = `
|
||||
INSERT INTO smirk_challenges (challenge, socket_id, user_id)
|
||||
SELECT $1::varchar(64), $2::varchar(255), id
|
||||
FROM users
|
||||
WHERE id = $3 AND socket_id = $2 AND anon_token = $4
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
/**
|
||||
* The host a NIP-98 auth event must be signed for.
|
||||
*
|
||||
@@ -260,13 +270,7 @@ router.post('/api/auth/smirk/challenge', challengeRateLimit, asyncHandler(async
|
||||
|
||||
// Store a stable user binding as well as the current socket. The user binding is what
|
||||
// prevents a public Tavern/Match socket id from being used to attach an attacker's key.
|
||||
const inserted = await db.query(`
|
||||
INSERT INTO smirk_challenges (challenge, socket_id, user_id)
|
||||
SELECT $1, $2, id
|
||||
FROM users
|
||||
WHERE id = $3 AND socket_id = $2 AND anon_token = $4
|
||||
RETURNING id
|
||||
`, [challenge, socketId, session.userId, session.token]);
|
||||
const inserted = await db.query(challengeInsertQuery, [challenge, socketId, session.userId, session.token]);
|
||||
if (inserted.rows.length !== 1) throw unauthorized();
|
||||
|
||||
// Opportunistic cleanup of expired and long-used challenges.
|
||||
@@ -470,3 +474,5 @@ router.get('/api/auth/smirk/status', asyncHandler(async (req, res) => {
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports.challengeInsertQuery = challengeInsertQuery;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
// Gameplay-only browser check. This deliberately avoids the application entrypoint, database,
|
||||
// wallet, payment handlers, and queue/race transitions. Mica receives synthetic Interior and Iso
|
||||
// grants; Moth receives the source-owned zero-grant catalog fixture and must remain original-only.
|
||||
// Local browser checks cover the real TavernManager, pack-gated rendering, and a client-only
|
||||
// account/payout visibility matrix. They do not start the application entrypoint, database, live
|
||||
// wallet, payment handlers, or multiplayer queue. Mica receives synthetic Interior and Iso grants;
|
||||
// Moth receives the source-owned zero-grant catalog fixture and must remain original-only.
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
@@ -48,6 +49,209 @@ function isLoopback(hostname) {
|
||||
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]';
|
||||
}
|
||||
|
||||
function accountGameModeInfo({ smirkEnabled, payoutsEnabled }) {
|
||||
return {
|
||||
mode: payoutsEnabled ? 'PAID_SINGLE' : 'FREE',
|
||||
freePlayEnabled: true,
|
||||
earlyEntry: { enabled: false },
|
||||
creditsPerGame: 1,
|
||||
creditsPayoutsEnabled: false,
|
||||
directModeEnabled: payoutsEnabled,
|
||||
directPayoutsEnabled: payoutsEnabled,
|
||||
paymentsEnabled: payoutsEnabled,
|
||||
smirkEnabled,
|
||||
cryptoType: 'WOW',
|
||||
currencyLabel: 'WOW',
|
||||
network: 'mainnet',
|
||||
singleGamePrice: 100000000000,
|
||||
singleGamePriceFormatted: '1 WOW',
|
||||
creditPackages: [],
|
||||
cosmeticProducts: [],
|
||||
payoutOutcomes: {},
|
||||
modes: {
|
||||
solo: false,
|
||||
tavern: true,
|
||||
multiplayer: true,
|
||||
match: { enabled: true, economies: { free: true } }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function runAccountVisibilityMatrix(page) {
|
||||
const initial = await page.evaluate(() => ({
|
||||
smirkSectionHidden: getComputedStyle(document.getElementById('smirkAccountSection')).display === 'none',
|
||||
payoutControlHidden: getComputedStyle(document.getElementById('manageAddressButton')).display === 'none'
|
||||
}));
|
||||
assert(initial.smirkSectionHidden && initial.payoutControlHidden,
|
||||
'Account or payout controls were visible before an explicit server game-mode snapshot');
|
||||
|
||||
await page.evaluate(data => SocketHandlers.onGameModeInfo(data),
|
||||
accountGameModeInfo({ smirkEnabled: true, payoutsEnabled: false }));
|
||||
const connectButton = page.getByRole('button', { name: 'Connect Smirk', exact: true });
|
||||
try {
|
||||
await connectButton.waitFor({ state: 'visible', timeout: 1500 });
|
||||
} catch (error) {
|
||||
const state = await page.evaluate(() => ({
|
||||
explicitFlag: SocketHandlers._smirkEnabled,
|
||||
authInitialized: SmirkAuth._initialized,
|
||||
providerAvailable: SmirkAuth.isAvailable(),
|
||||
sectionDisplay: getComputedStyle(document.getElementById('smirkAccountSection')).display,
|
||||
containerPresent: !!document.getElementById('smirkButtonContainer'),
|
||||
buttonPresent: !!document.getElementById('smirkLoginBtn'),
|
||||
containerContents: document.getElementById('smirkButtonContainer')?.textContent || ''
|
||||
}));
|
||||
throw new Error(`Smirk account entry did not become visible (${JSON.stringify(state)}): ${error.message}`);
|
||||
}
|
||||
await page.locator('#tavernButton').waitFor({ state: 'visible' });
|
||||
const freeAccount = await page.evaluate(() => {
|
||||
const region = document.getElementById('smirkAccountSection');
|
||||
const button = document.getElementById('smirkLoginBtn');
|
||||
const bounds = button.getBoundingClientRect();
|
||||
return {
|
||||
regionLabel: region.getAttribute('aria-label'),
|
||||
tavernEnabled: getComputedStyle(document.getElementById('tavernButton')).display !== 'none',
|
||||
payoutsControlHidden: getComputedStyle(document.getElementById('manageAddressButton')).display === 'none',
|
||||
addressModalHidden: document.getElementById('addressModal').classList.contains('hidden'),
|
||||
buttonText: button.textContent.trim(),
|
||||
buttonWidth: bounds.width,
|
||||
buttonRight: bounds.right,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
documentWidth: document.documentElement.scrollWidth
|
||||
};
|
||||
});
|
||||
assert(freeAccount.regionLabel === 'Smirk account' && freeAccount.tavernEnabled
|
||||
&& freeAccount.payoutsControlHidden && freeAccount.addressModalHidden,
|
||||
'Explicit Smirk access did not expose an accessible account entry while keeping payout controls closed');
|
||||
assert(freeAccount.buttonWidth > 0 && freeAccount.buttonRight <= freeAccount.viewportWidth
|
||||
&& freeAccount.documentWidth <= freeAccount.viewportWidth,
|
||||
'Smirk account entry is not usable within the mobile viewport');
|
||||
await page.evaluate(() => {
|
||||
window.__smirkAccountClickCount = 0;
|
||||
SmirkAuth._handleClick = function () { window.__smirkAccountClickCount += 1; };
|
||||
});
|
||||
await connectButton.click();
|
||||
await page.waitForFunction(() => window.__smirkAccountClickCount === 1);
|
||||
await page.evaluate(() => {
|
||||
AddressModal.show();
|
||||
SocketHandlers.onAddressPrompt({});
|
||||
});
|
||||
assert(await page.locator('#addressModal').evaluate(node => node.classList.contains('hidden')),
|
||||
'A payout address prompt opened while payout modes were disabled');
|
||||
|
||||
await page.evaluate(data => SocketHandlers.onGameModeInfo(data),
|
||||
accountGameModeInfo({ smirkEnabled: false, payoutsEnabled: false }));
|
||||
const defaultClosed = await page.evaluate(() => ({
|
||||
accountHidden: getComputedStyle(document.getElementById('smirkAccountSection')).display === 'none',
|
||||
payoutControlHidden: getComputedStyle(document.getElementById('manageAddressButton')).display === 'none'
|
||||
}));
|
||||
assert(defaultClosed.accountHidden && defaultClosed.payoutControlHidden,
|
||||
'The server-off baseline exposed Smirk or payout controls');
|
||||
|
||||
await page.evaluate(data => SocketHandlers.onGameModeInfo(data),
|
||||
accountGameModeInfo({ smirkEnabled: false, payoutsEnabled: true }));
|
||||
const payoutAccess = await page.evaluate(() => ({
|
||||
accountHidden: getComputedStyle(document.getElementById('smirkAccountSection')).display === 'none',
|
||||
payoutControlVisible: getComputedStyle(document.getElementById('manageAddressButton')).display !== 'none'
|
||||
}));
|
||||
assert(payoutAccess.accountHidden && payoutAccess.payoutControlVisible,
|
||||
'A permitted payout mode did not expose address management independently of Smirk');
|
||||
await page.locator('#manageAddressButton').click();
|
||||
await page.locator('#addressModal').waitFor({ state: 'visible' });
|
||||
await page.evaluate(data => SocketHandlers.onGameModeInfo(data),
|
||||
accountGameModeInfo({ smirkEnabled: false, payoutsEnabled: false }));
|
||||
assert(await page.locator('#addressModal').evaluate(node => node.classList.contains('hidden')),
|
||||
'Payout address modal stayed open after the server disabled payout modes');
|
||||
|
||||
return {
|
||||
explicitSmirkWithFreeTavern: freeAccount,
|
||||
serverOffBaseline: defaultClosed,
|
||||
permittedPayoutAddressManagement: payoutAccess,
|
||||
visibleSmirkButtonClickWired: true,
|
||||
unsolicitedPayoutPromptIgnored: true,
|
||||
payoutModalClosedWhenFeatureDisabled: true,
|
||||
syntheticSnapshotsOnly: true,
|
||||
provider: 'local Smirk stub; click handler intercepted before authentication'
|
||||
};
|
||||
}
|
||||
|
||||
async function runTavernAccountLink(page, outputDir) {
|
||||
await page.goto(`${page.url().split('/').slice(0, 3).join('/')}/tavern`);
|
||||
const accountLink = page.locator('#smirkAccountLink');
|
||||
await accountLink.waitFor({ state: 'attached' });
|
||||
assert(await accountLink.evaluate(node => node.hidden),
|
||||
'The Tavern exposed its Smirk account link before an explicit enabled snapshot');
|
||||
await page.evaluate(data => window.__deliverSocketEvent('game_mode_info', data),
|
||||
accountGameModeInfo({ smirkEnabled: false, payoutsEnabled: false }));
|
||||
assert(await accountLink.evaluate(node => node.hidden),
|
||||
'The Tavern exposed its Smirk account link when the server flag was off');
|
||||
await page.evaluate(data => window.__deliverSocketEvent('game_mode_info', data),
|
||||
accountGameModeInfo({ smirkEnabled: true, payoutsEnabled: false }));
|
||||
await accountLink.waitFor({ state: 'visible' });
|
||||
const linkState = await page.evaluate(() => {
|
||||
const link = document.getElementById('smirkAccountLink');
|
||||
const bounds = link.getBoundingClientRect();
|
||||
const header = document.querySelector('.tavern-head').getBoundingClientRect();
|
||||
const title = document.querySelector('.tavern-head h1').getBoundingClientRect();
|
||||
const nav = link.parentElement.getBoundingClientRect();
|
||||
return {
|
||||
href: link.getAttribute('href'),
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
linkRight: bounds.right,
|
||||
linkWidth: bounds.width,
|
||||
header: { left: header.left, right: header.right, width: header.width },
|
||||
title: { left: title.left, right: title.right, width: title.width },
|
||||
nav: { left: nav.left, right: nav.right, width: nav.width },
|
||||
controlsInsideHeader: header.left <= title.left && title.right <= header.right
|
||||
&& header.left <= nav.left && nav.right <= header.right
|
||||
};
|
||||
});
|
||||
assert(linkState.href === '/#smirkAccountSection' && linkState.linkWidth > 0
|
||||
&& linkState.linkRight <= linkState.viewportWidth
|
||||
&& linkState.documentWidth <= linkState.viewportWidth
|
||||
&& linkState.header.right <= linkState.viewportWidth && linkState.controlsInsideHeader,
|
||||
'The enabled Tavern account link is not usable within the mobile viewport');
|
||||
await page.getByRole('link', { name: 'Smirk account', exact: true }).focus();
|
||||
assert(await accountLink.evaluate(node => document.activeElement === node),
|
||||
'The enabled Tavern account link is not keyboard focusable');
|
||||
await page.screenshot({ path: path.join(outputDir, 'tavern-smirk-account-link.png'), fullPage: true });
|
||||
await accountLink.click();
|
||||
await page.waitForURL('**/#smirkAccountSection');
|
||||
await page.waitForFunction(() => document.readyState === 'complete'
|
||||
&& typeof SocketHandlers !== 'undefined' && typeof SmirkAuth !== 'undefined');
|
||||
await page.evaluate(data => SocketHandlers.onGameModeInfo(data),
|
||||
accountGameModeInfo({ smirkEnabled: true, payoutsEnabled: false }));
|
||||
const destination = await page.evaluate(() => ({
|
||||
path: location.pathname,
|
||||
hash: location.hash,
|
||||
sectionFocusable: document.activeElement === document.getElementById('smirkAccountSection'),
|
||||
sectionVisible: getComputedStyle(document.getElementById('smirkAccountSection')).display !== 'none',
|
||||
payoutControlHidden: getComputedStyle(document.getElementById('manageAddressButton')).display === 'none'
|
||||
}));
|
||||
assert(destination.path === '/' && destination.hash === '#smirkAccountSection'
|
||||
&& destination.sectionFocusable && destination.sectionVisible && destination.payoutControlHidden,
|
||||
'The Tavern account link did not land at the enabled, payout-free lobby account section');
|
||||
await page.screenshot({ path: path.join(outputDir, 'lobby-smirk-account-entry.png'), fullPage: true });
|
||||
const connectButton = page.getByRole('button', { name: 'Connect Smirk', exact: true });
|
||||
await connectButton.waitFor({ state: 'visible' });
|
||||
await page.evaluate(() => {
|
||||
window.__smirkAccountClickCount = 0;
|
||||
SmirkAuth._handleClick = function () { window.__smirkAccountClickCount += 1; };
|
||||
});
|
||||
await connectButton.click();
|
||||
await page.waitForFunction(() => window.__smirkAccountClickCount === 1);
|
||||
return {
|
||||
mobileTavernLink: linkState,
|
||||
keyboardFocusable: true,
|
||||
lobbyDestination: destination,
|
||||
visibleButtonClickWired: true,
|
||||
screenshots: {
|
||||
tavern: 'tavern-smirk-account-link.png',
|
||||
lobby: 'lobby-smirk-account-entry.png'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assert(process.env.NODE_ENV !== 'production', 'Refusing to run with NODE_ENV=production');
|
||||
assert(fs.existsSync(path.join(HTML_ROOT, 'tavern.html')), 'Tavern source page is missing');
|
||||
@@ -91,6 +295,13 @@ async function main() {
|
||||
app.get('/runtime-config.js', (_req, res) => {
|
||||
res.type('application/javascript').send('window.WOWNGEON_RUNTIME = {rendererLocalEnabled:false, rendererCdnEnabled:false};');
|
||||
});
|
||||
app.get('/api/game-modes', (_req, res) => res.json({ hostedBy: null }));
|
||||
app.get('/api/disclosures', (_req, res) => res.json({
|
||||
policyVersion: 'local-browser-fixture-v1',
|
||||
service: { anyPayoutsEnabled: false }
|
||||
}));
|
||||
app.get('/api/stats', (_req, res) => res.json({ gamesToday: 0, escapesToday: 0, payoutsEnabled: false }));
|
||||
app.get('/api/auth/smirk/status', (_req, res) => res.json({ linked: false, hasPayoutAddress: false }));
|
||||
app.get('/tavern', (_req, res) => res.sendFile(path.join(HTML_ROOT, 'tavern.html')));
|
||||
app.use(express.static(HTML_ROOT));
|
||||
|
||||
@@ -177,6 +388,62 @@ async function main() {
|
||||
}
|
||||
|
||||
await Promise.all(Object.values(views).map(({ page }) => page.goto(`${origin}/tavern`)));
|
||||
|
||||
const accountContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||
accountContext.on('requestfailed', request => {
|
||||
const url = new URL(request.url());
|
||||
if (!isLoopback(url.hostname) || Number(url.port || 80) !== address.port) {
|
||||
blockedExternal.push(url.origin);
|
||||
}
|
||||
});
|
||||
await accountContext.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.protocol === 'data:' || url.protocol === 'blob:') return route.continue();
|
||||
if (url.protocol === 'http:' && isLoopback(url.hostname) && Number(url.port || 80) === address.port) {
|
||||
return route.continue();
|
||||
}
|
||||
blockedExternal.push(url.origin);
|
||||
return route.abort('blockedbyclient');
|
||||
});
|
||||
const accountPage = await accountContext.newPage();
|
||||
await accountPage.addInitScript(() => {
|
||||
window.smirk = {
|
||||
async connect() { return { wow: {} }; },
|
||||
async getPublicKeys() { return {}; },
|
||||
async getAddresses() { return {}; }
|
||||
};
|
||||
});
|
||||
await accountPage.route('**/socket.io/socket.io.js', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/javascript',
|
||||
body: `window.io = function () {\n` +
|
||||
` var listeners = {};\n` +
|
||||
` var client = { id: 'smirk-account-browser-fixture', connected: true,\n` +
|
||||
` on: function (name, fn) { (listeners[name] || (listeners[name] = [])).push(fn); },\n` +
|
||||
` emit: function () {}\n` +
|
||||
` };\n` +
|
||||
` window.__deliverSocketEvent = function (name, data) { (listeners[name] || []).forEach(function (fn) { fn(data); }); };\n` +
|
||||
` window.socket = client;\n` +
|
||||
` return client;\n` +
|
||||
`};`
|
||||
}));
|
||||
accountPage.on('pageerror', error => browserFailures.push(error.message));
|
||||
accountPage.on('console', message => {
|
||||
if (message.type() === 'error') browserFailures.push(message.text());
|
||||
});
|
||||
accountPage.on('response', response => {
|
||||
if (response.status() >= 400) {
|
||||
observedHttpFailures.push({ path: new URL(response.url()).pathname, status: response.status() });
|
||||
}
|
||||
});
|
||||
await accountPage.goto(`${origin}/`);
|
||||
await accountPage.waitForFunction(() => document.readyState === 'complete'
|
||||
&& typeof SocketHandlers !== 'undefined' && typeof SmirkAuth !== 'undefined'
|
||||
&& typeof InputHandler !== 'undefined' && window.socket?.connected);
|
||||
await accountPage.waitForTimeout(250);
|
||||
const accountUi = await runAccountVisibilityMatrix(accountPage);
|
||||
const tavernAccountLink = await runTavernAccountLink(accountPage, outputDir);
|
||||
|
||||
for (const { page } of Object.values(views)) {
|
||||
await page.locator('#joinBtn').waitFor({ state: 'visible' });
|
||||
await page.waitForFunction(() => document.getElementById('joinBtn') && !document.getElementById('joinBtn').disabled);
|
||||
@@ -418,7 +685,7 @@ async function main() {
|
||||
}
|
||||
const result = {
|
||||
ok: true,
|
||||
scope: 'gameplay-only Tavern browser smoke with isolated spectator layout fixture; no queue/race transitions',
|
||||
scope: 'local Tavern lifecycle/graphics smoke plus a client-only account visibility matrix; no paid gameplay, queue/race transitions, database, or live wallet',
|
||||
origin: '127.0.0.1 loopback only',
|
||||
fixture: 'Mica receives synthetic roguelike-interior and iso-dungeon grants; Moth receives the authoritative zero-grant, original-only snapshot; both have zero balances and no purchase, skin or 3D grant',
|
||||
clients: { count: clientsByRole.size, mappedByPageOwnedSocketId: true },
|
||||
@@ -430,10 +697,13 @@ async function main() {
|
||||
mobile,
|
||||
activeArena: { desktop: desktopArena, mobile: mobileArena, arenaReceivesSafeInitialFocus: true, tavernFocusRestored: true },
|
||||
missingGeneratedSkin: skinFailure,
|
||||
accountUi,
|
||||
tavernAccountLink,
|
||||
screenshots: {
|
||||
syntheticInteriorCharacter: 'tavern-synthetic-interior-character.png',
|
||||
freeOriginalBaseline: screenshotPaths.moth,
|
||||
desktopIso: 'tavern-desktop-iso.png', mobile: screenshotPaths
|
||||
desktopIso: 'tavern-desktop-iso.png', mobile: screenshotPaths,
|
||||
smirkAccount: tavernAccountLink.screenshots
|
||||
},
|
||||
unexpectedHttpFailures,
|
||||
externalRequestsBlocked: blockedExternal.length,
|
||||
|
||||
@@ -12,6 +12,7 @@ const path = require('node:path');
|
||||
const { promisify } = require('node:util');
|
||||
const { Client } = require('pg');
|
||||
const { chromium } = require('playwright-core');
|
||||
const { challengeInsertQuery } = require('../routes/auth');
|
||||
|
||||
const execFile = promisify(childProcess.execFile);
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
@@ -143,6 +144,7 @@ function classifyStartupFailure(output) {
|
||||
|
||||
function classifyHarnessFailure(error, phase) {
|
||||
const sqlStateCodes = {
|
||||
'42P08': 'DATABASE_PARAMETER_TYPE_CONFLICT',
|
||||
'42P01': 'DATABASE_TABLE_UNDEFINED',
|
||||
'42703': 'DATABASE_COLUMN_UNDEFINED',
|
||||
'42883': 'DATABASE_FUNCTION_UNDEFINED',
|
||||
@@ -370,6 +372,19 @@ async function main() {
|
||||
throw error;
|
||||
});
|
||||
|
||||
// PREPARE checks the route's actual parameter inference without generating a
|
||||
// challenge, activating Smirk, or passing any authentication values to PostgreSQL.
|
||||
phase = 'auth-query-compile';
|
||||
const authDb = new Client({ host: socketDir, port: dbPort, database: dbName, user: dbUser,
|
||||
connectionTimeoutMillis: 2000, statement_timeout: 5000, lock_timeout: 2000 });
|
||||
try {
|
||||
await authDb.connect();
|
||||
await authDb.query(`PREPARE showcase_smirk_challenge AS ${challengeInsertQuery}`);
|
||||
await authDb.query('DEALLOCATE showcase_smirk_challenge');
|
||||
} finally {
|
||||
await authDb.end();
|
||||
}
|
||||
|
||||
phase = 'browser-setup';
|
||||
browser = await chromium.launch({ headless: true });
|
||||
for (const role of ROLES) {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
class SocketFixture {
|
||||
constructor() {
|
||||
this.connected = false;
|
||||
this.id = 'ordering-fixture-socket';
|
||||
this.listeners = new Map();
|
||||
this.emitted = [];
|
||||
this.connectCount = 0;
|
||||
this.listenersReadyAtConnect = [];
|
||||
this.handshakeAuth = [];
|
||||
}
|
||||
|
||||
on(eventName, callback) {
|
||||
const callbacks = this.listeners.get(eventName) || [];
|
||||
callbacks.push(callback);
|
||||
this.listeners.set(eventName, callbacks);
|
||||
}
|
||||
|
||||
emit(eventName, payload) {
|
||||
this.emitted.push({ eventName, payload });
|
||||
}
|
||||
|
||||
deliver(eventName, payload) {
|
||||
for (const callback of this.listeners.get(eventName) || []) callback(payload);
|
||||
}
|
||||
|
||||
connect(authProvider) {
|
||||
if (this.connected) return;
|
||||
if (authProvider === undefined) authProvider = this.authProvider;
|
||||
let auth = null;
|
||||
if (typeof authProvider === 'function') {
|
||||
authProvider(payload => { auth = payload; });
|
||||
}
|
||||
this.handshakeAuth.push(auth);
|
||||
this.connected = true;
|
||||
this.connectCount += 1;
|
||||
this.listenersReadyAtConnect.push(
|
||||
['connect', 'session_token', 'session_resumed'].every(name => this.listeners.has(name))
|
||||
);
|
||||
|
||||
// The server can emit session state immediately after the client's connect event.
|
||||
this.deliver('connect');
|
||||
if (this.connectCount === 1) {
|
||||
this.deliver('session_token', { token: 'fixture-session-first', userId: 41 });
|
||||
} else if (this.connectCount === 2) {
|
||||
this.deliver('session_resumed', { token: 'fixture-session-resumed', userId: 41, credits: 0 });
|
||||
} else {
|
||||
this.deliver('session_resumed', { token: 'fixture-session-latest', userId: 41, credits: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (!this.connected) return;
|
||||
this.connected = false;
|
||||
this.deliver('disconnect');
|
||||
}
|
||||
}
|
||||
|
||||
function makeJQueryStub() {
|
||||
return function jqueryStub() {
|
||||
const chain = {
|
||||
length: 0,
|
||||
on() { return this; },
|
||||
append() { return this; },
|
||||
remove() { return this; },
|
||||
hide() { return this; },
|
||||
show() { return this; },
|
||||
val() { return this; },
|
||||
text() { return this; },
|
||||
html() { return this; },
|
||||
css() { return this; },
|
||||
prop() { return this; },
|
||||
removeData() { return this; }
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
}
|
||||
|
||||
function loadLobbyClient({ storageUnavailable = false } = {}) {
|
||||
const html = fs.readFileSync(require.resolve('../html/index.html'), 'utf8');
|
||||
const bootstrap = html.match(/<script>\s*([\s\S]*?)<\/script>/);
|
||||
if (!bootstrap) throw new Error('Lobby socket bootstrap script was not found');
|
||||
|
||||
const localValues = new Map();
|
||||
const localStorage = {
|
||||
getItem: key => {
|
||||
if (storageUnavailable) throw new Error('storage unavailable');
|
||||
return localValues.get(key) || null;
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
if (storageUnavailable) throw new Error('storage unavailable');
|
||||
localValues.set(key, String(value));
|
||||
},
|
||||
removeItem: key => localValues.delete(key)
|
||||
};
|
||||
const socket = new SocketFixture();
|
||||
let socketOptions = null;
|
||||
const context = {
|
||||
window: {
|
||||
location: { origin: 'https://wowngeon.test' },
|
||||
localStorage,
|
||||
navigator: { userAgent: 'socket-session-ordering-test' }
|
||||
},
|
||||
document: {
|
||||
addEventListener() {},
|
||||
getElementById() { return null; }
|
||||
},
|
||||
localStorage,
|
||||
navigator: { userAgent: 'socket-session-ordering-test' },
|
||||
io(options) {
|
||||
socketOptions = options;
|
||||
socket.authProvider = options.auth;
|
||||
if (options.autoConnect !== false) socket.connect(options.auth);
|
||||
return socket;
|
||||
},
|
||||
socket,
|
||||
$: makeJQueryStub(),
|
||||
UI: { scrollChat() {} },
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
fetch: async () => ({ json: async () => ({}) }),
|
||||
setTimeout() { return 1; }
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(bootstrap[1], context, { filename: 'html/index.html#socket-bootstrap' });
|
||||
vm.runInContext(
|
||||
`${fs.readFileSync(require.resolve('../html/js/network/socketHandlers'), 'utf8')}\nthis.socketHandlers = SocketHandlers;`,
|
||||
context,
|
||||
{ filename: 'html/js/network/socketHandlers.js' }
|
||||
);
|
||||
return { context, localValues, socket, socketOptions };
|
||||
}
|
||||
|
||||
describe('lobby socket session event ordering', () => {
|
||||
test('registers session listeners before connecting and restores the token on reconnect', () => {
|
||||
const { context, localValues, socket, socketOptions } = loadLobbyClient();
|
||||
|
||||
expect(socketOptions.autoConnect).toBe(false);
|
||||
expect(socket.connected).toBe(false);
|
||||
context.socketHandlers.init();
|
||||
|
||||
expect(socket.listenersReadyAtConnect).toEqual([true]);
|
||||
expect(socket.handshakeAuth).toEqual([{}]);
|
||||
expect(context.socketHandlers._sessionUserId).toBe(41);
|
||||
expect(localValues.get('wownerogue_token')).toBe('fixture-session-first');
|
||||
expect(socket.emitted.filter(event => event.eventName === 'register_client')).toHaveLength(1);
|
||||
|
||||
context.socketHandlers.init();
|
||||
expect(socket.connectCount).toBe(1);
|
||||
expect(socket.emitted.filter(event => event.eventName === 'register_client')).toHaveLength(1);
|
||||
|
||||
socket.disconnect();
|
||||
expect(context.socketHandlers._sessionUserId).toBeNull();
|
||||
socket.connect(socketOptions.auth);
|
||||
|
||||
expect(socket.listenersReadyAtConnect).toEqual([true, true]);
|
||||
expect(socket.handshakeAuth).toEqual([{}, { resumeToken: 'fixture-session-first' }]);
|
||||
expect(context.socketHandlers._sessionUserId).toBe(41);
|
||||
expect(localValues.get('wownerogue_token')).toBe('fixture-session-resumed');
|
||||
expect(socket.emitted.filter(event => event.eventName === 'register_client')).toHaveLength(2);
|
||||
|
||||
socket.disconnect();
|
||||
socket.connect(socketOptions.auth);
|
||||
expect(socket.handshakeAuth).toEqual([
|
||||
{},
|
||||
{ resumeToken: 'fixture-session-first' },
|
||||
{ resumeToken: 'fixture-session-resumed' }
|
||||
]);
|
||||
expect(localValues.get('wownerogue_token')).toBe('fixture-session-latest');
|
||||
expect(socket.emitted.filter(event => event.eventName === 'register_client')).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('leaves session resume disabled when storage is unavailable', () => {
|
||||
const { context, socket, socketOptions } = loadLobbyClient({ storageUnavailable: true });
|
||||
|
||||
expect(socketOptions.auth).toBeUndefined();
|
||||
context.socketHandlers.init();
|
||||
|
||||
expect(socket.connected).toBe(true);
|
||||
expect(socket.handshakeAuth).toEqual([null]);
|
||||
expect(context.socketHandlers._sessionUserId).toBe(41);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user