Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dashboard/backend/tests/test_admin_analytics_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ def test_app_lifecycle_and_cache_versions_are_wired():
# Lockstep owner for the console's bumped tags and the /admin page's pins:
# every bump edits this test in the same change (Global Constraints).
assert 'styles.css?v=159' in APP_HTML
assert 'app.js?v=159' in APP_HTML
assert 'app.js?v=160' in APP_HTML
assert 'js/admin-tabs.js?v=12' in APP_HTML
for tag in (
'href="admin.css?v=13"',
Expand Down
2 changes: 1 addition & 1 deletion dashboard/backend/tests/test_analytics_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def _function_body(source: str, signature: str) -> str:


def test_analytics_script_loads_between_app_and_page_scripts():
app_at = APP_HTML.index('<script src="app.js?v=159" defer></script>')
app_at = APP_HTML.index('<script src="app.js?v=160" defer></script>')
analytics_at = APP_HTML.index(
'<script src="js/analytics.js?v=1" defer></script>'
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ def test_exact_raw_ties_mark_every_tied_series_best():

def test_comparison_script_and_semantic_table_ship_before_app():
helper = '<script src="js/backtest-comparison.js?v=1" defer></script>'
app = '<script src="app.js?v=159" defer></script>'
app = '<script src="app.js?v=160" defer></script>'
assert 'href="styles.css?v=159"' in APP_HTML
assert APP_HTML.index(helper) < APP_HTML.index(app)
for element_id in (
Expand Down
2 changes: 1 addition & 1 deletion dashboard/backend/tests/test_frontend_fast_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ def test_cache_busters_bumped():
# the next bump, so the exact one looks like the broken guard and gets
# "fixed" by loosening it. That collision has already cost this repo one
# round of follow-ups (#347/#348).
assert "app.js?v=159" in APP_HTML
assert "app.js?v=160" in APP_HTML
assert "js/agent-editor.js?v=32" in APP_HTML
assert "styles.css?v=159" in APP_HTML
assert "js/leaderboard.js?v=38" in APP_HTML
Expand Down
95 changes: 95 additions & 0 deletions dashboard/backend/tests/test_research_guest_shelf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Execute the real shelf renderer for guest, owner, and network failure states."""
import json
import shutil
import subprocess
from pathlib import Path

import pytest

APP = (Path(__file__).parents[2] / 'frontend' / 'app.js').read_text()
RENDER = APP[APP.index('let researchShelfRenderVersion = 0;'):APP.index('/** The Community research card', APP.index('async function renderResearchShelf()'))]


@pytest.mark.parametrize('mode', ['guest', 'owner', 'failure'])
def test_research_shelf_access_states(mode):
if not shutil.which('node'):
pytest.skip('Node required')
script = r'''
const mode = MODE;
const section = {style:{display:'none'}};
let handler, login = false, workbench = false;
const card = {dataset:{templateId:'research'},querySelector: s => s === '.research-open-btn' ? {addEventListener: (_,fn)=>handler=fn} : null};
const grid = {innerHTML:'',closest:()=>section,querySelectorAll:()=>[card]};
const empty = {}, count = {};
const document = {getElementById:id=>({agentsGridResearch:grid,agentsEmptyResearch:empty,agentsCountResearch:count}[id])};
const agent = {template_id:'research',name:'Research demo',research:{},added:true};
const fetchResearchAgents = async()=> {if(mode==='owner') return [agent,{...agent,name:'Unadded',added:false}]; throw {status:mode==='guest'?401:503};};
let catalogCalls=0;
const API = {get:async()=>{catalogCalls++;return {templates:[agent,{name:'Trading',template_id:'trading'}]};}};
const API_BASE='';
const templateMarketplaceShelf = a=>a.research?'research':'open';
const escapeHtml=s=>String(s??'');
const openAuthModal=()=>login=true;
const openResearchWorkbench=()=>workbench=true;
const communityShelfButtonHtml=()=>'';
RENDER
(async()=>{await renderResearchShelf();if(handler)handler();console.log(JSON.stringify({html:grid.innerHTML,display:section.style.display,login,workbench,catalogCalls,error:empty.textContent}));})().catch(e=>{console.error(e);process.exit(1)});
'''.replace('MODE', json.dumps(mode)).replace('RENDER', RENDER)
result = json.loads(subprocess.check_output(['node', '-e', script], text=True))
assert result['display'] == ''
if mode == 'guest':
assert 'Research demo' in result['html']
assert 'Sign in to use' in result['html']
assert 'research-remove-btn' not in result['html']
assert 'Trading' not in result['html']
assert result['login'] and not result['workbench']
assert result['catalogCalls'] == 1
elif mode == 'owner':
assert 'Open workbench' in result['html']
assert 'Unadded' not in result['html']
assert result['workbench'] and not result['login']
assert result['catalogCalls'] == 0
else:
assert 'temporarily unavailable' in result['error']
assert result['catalogCalls'] == 0


@pytest.mark.parametrize('reject_old', [False, True])
def test_pending_guest_response_cannot_overwrite_signed_in_shelf(reject_old):
if not shutil.which('node'):
pytest.skip('Node required')
script = r"""
const section = {style:{}};
const grid = {innerHTML:'',closest:()=>section,querySelectorAll:()=>[]};
const empty = {}, count = {};
const document = {getElementById:id=>({agentsGridResearch:grid,agentsEmptyResearch:empty,agentsCountResearch:count}[id])};
let signedIn = false, resolveCatalog, rejectCatalog, started;
const catalogStarted = new Promise(resolve=>started=resolve);
const catalog = new Promise((resolve,reject)=>{resolveCatalog=resolve;rejectCatalog=reject;});
const agent = {template_id:'research',name:'Owned research',research:{},added:true};
const fetchResearchAgents = async()=>{if(signedIn)return [agent];throw {status:401};};
const API = {get:()=>{started();return catalog;}};
const API_BASE='';
const templateMarketplaceShelf=()=> 'research';
const escapeHtml=s=>String(s??'');
const communityShelfButtonHtml=()=>'';
RENDER
(async()=>{
const oldRender=renderResearchShelf();
await catalogStarted;
signedIn=true;
await renderResearchShelf();
const expected=grid.innerHTML;
if(REJECT)rejectCatalog(new Error('Late failure'));
else resolveCatalog({templates:[{...agent,name:'Guest research'}]});
await oldRender;
console.log(JSON.stringify({unchanged:expected===grid.innerHTML,html:grid.innerHTML,error:empty.textContent}));
})().catch(e=>{console.error(e);process.exit(1)});
""".replace('RENDER', RENDER).replace('REJECT', json.dumps(reject_old))
result = json.loads(subprocess.check_output(['node', '-e', script], text=True))
assert result['unchanged']
assert 'Owned research' in result['html']
assert 'Open workbench' in result['html']
assert 'research-remove-btn' in result['html']
assert 'Guest research' not in result['html']
assert not result.get('error')
2 changes: 1 addition & 1 deletion dashboard/frontend/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -2512,7 +2512,7 @@ <h2 id="creditsRefundTitle">Refund Credits purchase</h2>
<script src="market-events/MarketEventFeed.js" defer></script>
<script src="js/portfolio.js?v=18" defer></script>
<script src="js/backtest-comparison.js?v=1" defer></script>
<script src="app.js?v=159" defer></script>
<script src="app.js?v=160" defer></script>
<script src="js/analytics.js?v=1" defer></script>
<script src="js/agent-editor.js?v=32" defer></script>
<script src="js/credit-format.js?v=1" defer></script>
Expand Down
35 changes: 26 additions & 9 deletions dashboard/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -13370,24 +13370,40 @@ function researchStatusLabel(status) {
}

/** The My Agents "Research Agents" shelf: cards for cloned research agents. */
let researchShelfRenderVersion = 0;

async function renderResearchShelf() {
const renderVersion = ++researchShelfRenderVersion;
const grid = document.getElementById('agentsGridResearch');
const emptyEl = document.getElementById('agentsEmptyResearch');
const countEl = document.getElementById('agentsCountResearch');
if (!grid) return;
const section = grid.closest('.agents-category');
if (section) section.style.display = '';
let agents = [];
let guest = false;
try {
agents = await fetchResearchAgents();
try {
agents = await fetchResearchAgents();
} catch (error) {
if (renderVersion !== researchShelfRenderVersion) return;
if (error?.status !== 401) throw error;
guest = true;
const catalog = await API.get(`${API_BASE}/api/v1/agents/marketplace`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Discard stale guest renders after sign-in

If a visitor signs in while this marketplace fetch is pending, the sign-in flow triggers another renderResearchShelf() through loadAgentsNow(), but the older guest invocation can resume afterward and overwrite the authenticated cards with “Sign in to use” buttons and no Remove actions. Because concurrent renders are neither serialized nor checked against the current authentication state, a slow catalog response can leave a signed-in user seeing the guest shelf until another refresh; discard stale results or revalidate auth before applying the fallback.

Useful? React with 👍 / 👎.

agents = (catalog.templates || []).filter(agent => templateMarketplaceShelf(agent) === 'research');
}
} catch (error) {
// A 401 here just means the visitor is not signed in — the shelf hides
// rather than advertising an error for a surface guests cannot use.
if (renderVersion !== researchShelfRenderVersion) return;
grid.innerHTML = '';
if (emptyEl) emptyEl.hidden = true;
const section = grid.closest('.agents-category');
if (section) section.style.display = error?.status === 401 ? 'none' : '';
if (countEl) countEl.hidden = true;
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = 'Research agents are temporarily unavailable. Reopen this page to retry.';
}
return;
}
const added = agents.filter((agent) => agent.added);
if (renderVersion !== researchShelfRenderVersion) return;
const added = guest ? agents : agents.filter((agent) => agent.added);
if (countEl) {
countEl.textContent = String(added.length);
countEl.hidden = added.length === 0;
Expand Down Expand Up @@ -13416,13 +13432,14 @@ async function renderResearchShelf() {
<svg class="ui-icon research-fact-icon" aria-hidden="true"><use href="#icon-file-text"></use></svg> ${escapeHtml(((agent.research || {}).output_formats || []).join(' / '))}
</p>
<div class="research-agent-card-actions">
<button type="button" class="auth-btn auth-btn-primary research-open-btn">Open workbench</button>
<button type="button" class="research-remove-btn" data-remove-template-id="${escapeHtml(agent.template_id)}" data-agent-name="${escapeHtml(agent.name)}">Remove</button>
<button type="button" class="auth-btn auth-btn-primary research-open-btn">${guest ? 'Sign in to use' : 'Open workbench'}</button>
${guest ? '' : `<button type="button" class="research-remove-btn" data-remove-template-id="${escapeHtml(agent.template_id)}" data-agent-name="${escapeHtml(agent.name)}">Remove</button>`}
</div>
</article>`;
}).join('');
grid.querySelectorAll('.research-agent-card').forEach((card) => {
card.querySelector('.research-open-btn')?.addEventListener('click', () => {
if (guest) { openAuthModal('login'); return; }
openResearchWorkbench(card.dataset.templateId);
});
card.querySelector('.research-remove-btn')?.addEventListener('click', async (event) => {
Expand Down
Loading