208 lines
7.8 KiB
Python
208 lines
7.8 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
FRONTEND = Path(__file__).parents[1] / "frontend"
|
|
HYDRATOR = FRONTEND / "conversation-action-hydrator.js"
|
|
|
|
|
|
def run_node(script: str):
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_conversation_paints_before_optional_actions_load_and_hydrates_once():
|
|
script = f"""
|
|
const createConversationActionHydrator = require({json.dumps(str(HYDRATOR))});
|
|
const events = [];
|
|
let finishLoad;
|
|
const hydrator = createConversationActionHydrator({{
|
|
load: () => {{
|
|
events.push('load');
|
|
return new Promise(resolve => {{ finishLoad = resolve; }});
|
|
}},
|
|
activate: () => ({{name:'actions'}}),
|
|
}});
|
|
const root = {{}};
|
|
const retry = {{hidden:false}};
|
|
const status = {{textContent:'2 messages loaded.'}};
|
|
const state = {{comments:[1,2]}};
|
|
const pending = hydrator.show({{
|
|
root, state, retry, status,
|
|
paint: (_state, actions) => events.push(actions ? 'paint-actions' : 'paint-core'),
|
|
wire: () => events.push('wire'),
|
|
}});
|
|
events.push('returned');
|
|
finishLoad();
|
|
pending.then(() => {{
|
|
hydrator.show({{
|
|
root, state, retry, status,
|
|
paint: (_state, actions) => events.push(actions ? 'paint-actions-again' : 'paint-core-again'),
|
|
wire: () => events.push('wire-again'),
|
|
}}).then(() => process.stdout.write(JSON.stringify({{events,retry,status:status.textContent}})));
|
|
}});
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output == {
|
|
"events": [
|
|
"paint-core", "load", "returned", "wire", "paint-actions",
|
|
"paint-actions-again",
|
|
],
|
|
"retry": {"hidden": True},
|
|
"status": "2 messages loaded.",
|
|
}
|
|
|
|
|
|
def test_failed_comment_actions_keep_conversation_usable_and_retry_in_place():
|
|
script = f"""
|
|
const createConversationActionHydrator = require({json.dumps(str(HYDRATOR))});
|
|
const events = [];
|
|
let attempt = 0;
|
|
const hydrator = createConversationActionHydrator({{
|
|
load: async () => {{
|
|
attempt += 1;
|
|
if (attempt === 1) throw new Error('chunk unavailable');
|
|
}},
|
|
activate: () => ({{name:'actions'}}),
|
|
}});
|
|
const root = {{}};
|
|
const retry = {{hidden:true}};
|
|
const status = {{textContent:'2 messages loaded.'}};
|
|
const options = {{
|
|
root, retry, status, state:{{comments:[1,2]}},
|
|
paint: (_state, actions) => events.push(actions ? 'actions' : 'core'),
|
|
wire: () => events.push('wire'),
|
|
}};
|
|
(async () => {{
|
|
const failed = await hydrator.show(options);
|
|
events.push('reply-still-usable');
|
|
const recovered = await hydrator.show(options);
|
|
process.stdout.write(JSON.stringify({{
|
|
failed, recovered, attempt, events, retry, status:status.textContent,
|
|
}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output == {
|
|
"failed": False,
|
|
"recovered": True,
|
|
"attempt": 2,
|
|
"events": ["core", "reply-still-usable", "core", "wire", "actions"],
|
|
"retry": {"hidden": True},
|
|
"status": "2 messages loaded.",
|
|
}
|
|
|
|
|
|
def test_other_focused_surfaces_can_request_the_same_lazy_action_controller():
|
|
script = f"""
|
|
const createConversationActionHydrator = require({json.dumps(str(HYDRATOR))});
|
|
let loads=0; const controller={{name:'shared-actions'}};
|
|
const hydrator=createConversationActionHydrator({{
|
|
load:async()=>{{loads++;}}, activate:()=>controller,
|
|
}});
|
|
(async()=>{{
|
|
const [first,second]=await Promise.all([hydrator.get(),hydrator.get()]);
|
|
process.stdout.write(JSON.stringify({{loads,same:first===second,name:first.name}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
assert run_node(script) == {"loads": 1, "same": True, "name": "shared-actions"}
|
|
|
|
|
|
def test_issue_pull_and_update_conversations_trigger_optional_actions_not_startup():
|
|
html = (FRONTEND / "index.html").read_text()
|
|
javascript = (FRONTEND / "dashboard.js").read_text()
|
|
|
|
assert '<script src="static/conversation-action-hydrator.js"></script>' in html
|
|
assert html.index("static/conversation-action-hydrator.js") < html.index("static/dashboard.js")
|
|
assert "await commentActionFeatures.run('comment-actions'" not in javascript
|
|
assert "load: () => commentActionFeatures.load('comment-actions')" in javascript
|
|
assert "createConversationActionHydrator" in javascript
|
|
for kind in ("issue", "pull", "update"):
|
|
assert f"retry-{kind}-comment-actions" in html
|
|
assert f"showConversationWithActions('{kind}'" in javascript
|
|
assert f"qs('#retry-{kind}-comment-actions').addEventListener" in javascript
|
|
|
|
|
|
def test_comment_action_retry_is_a_phone_sized_inline_control():
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
|
|
assert ".conversation-actions-retry" in css
|
|
rule = css.split(".conversation-actions-retry", 1)[1].split("}", 1)[0]
|
|
assert "min-height:44px" in rule
|
|
assert "max-width:100%" in rule
|
|
|
|
|
|
def test_search_and_following_preview_hydrates_owned_comment_actions_lazily():
|
|
preview = FRONTEND / "search-preview.js"
|
|
script = f"""
|
|
require({json.dumps(str(preview))});
|
|
const nodes = {{
|
|
'#search-preview-comments': {{innerHTML:'',textContent:''}},
|
|
'#search-preview-conversation-status': {{textContent:''}},
|
|
'#retry-search-preview-conversation': {{hidden:true}},
|
|
'#load-older-search-preview-comments': {{hidden:true,disabled:false}},
|
|
}};
|
|
const document = {{querySelector: selector => nodes[selector]}};
|
|
const actions = {{actionHtml: comment => comment.author === 'timmy'
|
|
? '<button data-comment-action="edit">Edit</button><button data-comment-reactions-open>React</button>'
|
|
: '<button data-comment-reactions-open>React</button>'}};
|
|
renderSearchPreviewConversation({{
|
|
status:'ready', reviewedAt:'2026-08-25T08:00:00Z',
|
|
comments:[
|
|
{{id:41,author:'timmy',body:'Mine',created_at:'2026-08-25T09:00:00Z'}},
|
|
{{id:42,author:'alexander',body:'Theirs',created_at:'2026-08-25T07:00:00Z'}},
|
|
],
|
|
}}, document, String, String, String, actions);
|
|
process.stdout.write(JSON.stringify({{html:nodes['#search-preview-comments'].innerHTML}}));
|
|
"""
|
|
output = run_node(script)["html"]
|
|
|
|
assert output.count('class="search-preview-comment issue-comment') == 2
|
|
assert output.count("data-comment-reactions-open") == 2
|
|
assert output.count('data-comment-action="edit"') == 1
|
|
assert 'data-comment-id="41"' in output
|
|
assert "new-since-review" in output
|
|
|
|
html = (FRONTEND / "index.html").read_text()
|
|
javascript = (FRONTEND / "dashboard.js").read_text()
|
|
assert 'id="retry-search-preview-comment-actions"' in html
|
|
assert "showSearchConversationWithActions" in javascript
|
|
assert "context:{kind:searchPreviewDetail.kind,item:searchPreviewDetail}" in javascript
|
|
|
|
|
|
def test_search_preview_comment_pager_updates_exact_visible_comment_in_place():
|
|
preview = FRONTEND / "search-preview.js"
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(preview))});
|
|
(async () => {{
|
|
const states=[];
|
|
const controller=createSearchPreview({{
|
|
fetchJson:async item=>({{...item,title:'Visible'}}),
|
|
fetchConversation:async()=>({{comments:[
|
|
{{id:41,author:'timmy',body:'Old'}},{{id:42,author:'alexander',body:'Keep'}},
|
|
],older_page:null}}),
|
|
mutate:async()=>({{}}), onState:state=>states.push(state),
|
|
}});
|
|
await controller.open({{repository:'stackchain/api',number:9,kind:'pull'}});
|
|
await new Promise(resolve=>setTimeout(resolve,0));
|
|
const pager=controller.commentPager();
|
|
pager.replace({{id:41,author:'timmy',body:'Corrected'}});
|
|
const replaced=pager.snapshot().comments;
|
|
pager.remove(41);
|
|
process.stdout.write(JSON.stringify({{
|
|
replaced:replaced.map(comment=>[comment.id,comment.body]),
|
|
remaining:pager.snapshot().comments.map(comment=>comment.id),
|
|
published:states.at(-1).conversation.comments.map(comment=>comment.id),
|
|
}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"replaced": [[41, "Corrected"], [42, "Keep"]],
|
|
"remaining": [42],
|
|
"published": [42],
|
|
}
|