Watch assigned issues and pull requests from mobile detail #1310
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py -q
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -108,9 +108,11 @@ overwriting newer views. Rename and delete affect only the saved view, never Git
|
|||
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
||||
`.stackchain-state/saved-searches.sqlite3` path.
|
||||
|
||||
Confirmed **Watch issue** and **Watch pull request** actions on open Search results feed the mobile
|
||||
**Following** queue, including work already assigned to you or a teammate. This completes the
|
||||
Search → watch → revisit flow without changing ownership, review assignment, or scheduling work.
|
||||
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
||||
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
||||
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
|
||||
after the account-scoped collection confirms the same mutation. This completes the read → watch → revisit flow without
|
||||
changing ownership, review assignment, or scheduling work.
|
||||
Following is a read-first, account-scoped collection: it is encrypted at rest, revisioned, bounded to
|
||||
50 typed items, and synchronized across signed-in devices. Opening a row reuses the correct issue or
|
||||
pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work
|
||||
|
|
|
|||
|
|
@ -955,6 +955,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.filed-claim-actions button { min-width:0; min-height:44px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions #watch-issue-detail, .pull-sheet-actions #watch-pull-detail { min-width:0; min-height:44px; }
|
||||
.detail-watch-status:not(:empty) { margin-top:8px; overflow-wrap:anywhere; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff, .pull-ownership { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }
|
||||
|
|
|
|||
|
|
@ -122,6 +122,18 @@
|
|||
},
|
||||
onReviewComplete:() => mobileStartDay.completePhase('following'),
|
||||
});
|
||||
function bindDetailWatch(kind, button, status) {
|
||||
const feature=createDetailWatch({fetchJson:fetchReviewJson,refreshFollowing:()=>followingQueue.load(),onState:(state,watching,error)=>{
|
||||
button.disabled=['loading','watching','unwatching'].includes(state);
|
||||
button.textContent=watching?'Stop watching':'Watch '+(kind==='pull'?'pull request':'issue');
|
||||
status.textContent=state==='ready'?(watching?'Watching · available in Following.':''):
|
||||
searchPreviewWatchStatus({status:state==='error'?'watch-error':state,error,detail:{kind}});
|
||||
}});
|
||||
button.addEventListener('click',()=>feature.toggle().catch(()=>{}));
|
||||
return feature;
|
||||
}
|
||||
const issueDetailWatch = bindDetailWatch('issue',qs('#watch-issue-detail'),qs('#issue-watch-status'));
|
||||
const pullDetailWatch = bindDetailWatch('pull',qs('#watch-pull-detail'),qs('#pull-watch-status'));
|
||||
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => draftInbox.partition(lastDrafts).deliveries,
|
||||
getIndex: item => lastDrafts.indexOf(item),
|
||||
|
|
@ -4275,6 +4287,15 @@
|
|||
issueConversation = null;
|
||||
issueTrigger = trigger;
|
||||
qs('#issue-sheet').classList.add('open');
|
||||
if (offlineDetail) {
|
||||
qs('#watch-issue-detail').hidden = true;
|
||||
qs('#issue-watch-status').textContent = 'Reconnect to change watch status.';
|
||||
} else {
|
||||
qs('#watch-issue-detail').hidden = item.state !== 'open';
|
||||
void issueDetailWatch.open(item).catch(error => {
|
||||
if (selectedIssue === item) qs('#issue-watch-status').textContent = error.message + ' Retry.';
|
||||
});
|
||||
}
|
||||
qs('#issue-sheet-key').textContent = item.key || '';
|
||||
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
|
||||
qs('#issue-sheet-status').textContent = 'Loading issue…';
|
||||
|
|
@ -4597,6 +4618,15 @@
|
|||
pullConversation = null;
|
||||
pullReviewState = null;
|
||||
qs('#pull-sheet').classList.add('open');
|
||||
if (offlineDetail) {
|
||||
qs('#watch-pull-detail').hidden = true;
|
||||
qs('#pull-watch-status').textContent = 'Reconnect to change watch status.';
|
||||
} else {
|
||||
qs('#watch-pull-detail').hidden = item.state !== 'open';
|
||||
void pullDetailWatch.open(item).catch(error => {
|
||||
if (selectedPull === item) qs('#pull-watch-status').textContent = error.message + ' Retry.';
|
||||
});
|
||||
}
|
||||
qs('#pull-sheet-key').textContent = item.key || '';
|
||||
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
|
||||
qs('#pull-sheet-status').textContent = 'Loading pull request…';
|
||||
|
|
|
|||
|
|
@ -1181,11 +1181,13 @@
|
|||
</section>
|
||||
<div class="issue-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="watch-issue-detail" type="button">Watch issue</button>
|
||||
<button id="release-issue" type="button">Release assignment</button>
|
||||
<button id="close-issue" type="button">Close issue</button>
|
||||
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<div id="issue-watch-status" class="detail-watch-status small" aria-live="polite"></div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
|
|
@ -1719,10 +1721,12 @@
|
|||
</details>
|
||||
<div class="pull-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="watch-pull-detail" type="button">Watch pull request</button>
|
||||
<button id="release-pull" type="button">Release assignment</button>
|
||||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<div id="pull-watch-status" class="detail-watch-status small" aria-live="polite"></div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,33 @@
|
|||
})});
|
||||
return options;
|
||||
};
|
||||
root.createDetailWatch = ({fetchJson,refreshFollowing,onState}) => {
|
||||
const api=root.searchPreviewSubscriptionOptions(fetchJson);
|
||||
let item,watching=false,mutation;
|
||||
return {
|
||||
async open(next) {
|
||||
item=next; onState('loading',watching);
|
||||
const result=await api.load(next).catch(error=>{
|
||||
if(item===next)onState('error',watching,error);throw error;
|
||||
});
|
||||
if (item !== next) return;
|
||||
watching=result.watching === true; onState('ready',watching);
|
||||
},
|
||||
toggle() {
|
||||
if (mutation) return mutation;
|
||||
const next=!watching;
|
||||
onState(next?'watching':'unwatching',watching);
|
||||
mutation=api.watch(item,next).then(async result => {
|
||||
if (result?.watching !== next || result?.following_synced !== true)
|
||||
throw new Error(result?.error || 'Unconfirmed.');
|
||||
watching=next; await refreshFollowing();
|
||||
onState(watching?'watched':'unwatched',watching);
|
||||
}).catch(error => {onState('error',watching,error);throw error;})
|
||||
.finally(()=>{mutation=null;});
|
||||
return mutation;
|
||||
},
|
||||
};
|
||||
};
|
||||
root.searchPreviewWatchStatus = state => ({
|
||||
watching:'Starting watch…', unwatching:'Stopping watch…',
|
||||
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
|||
number = 0
|
||||
comments = self.server.activity_comments.get(number, [])
|
||||
self._json(200, comments, **{"X-Total-Count": str(len(comments))})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/subscriptions/check"):
|
||||
self._json(200, {"subscribed": False, "ignored": False})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
|
||||
self._json(200, [])
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):
|
||||
|
|
|
|||
44
tests/e2e/test_mobile_detail_watch_release.py
Normal file
44
tests/e2e/test_mobile_detail_watch_release.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile detail-watch journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_issue_and_pull_detail_watch_controls_are_phone_usable(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
|
||||
for sheet, button, expected in [
|
||||
("#issue-sheet", "#watch-issue-detail", "Watch issue"),
|
||||
("#pull-sheet", "#watch-pull-detail", "Watch pull request"),
|
||||
]:
|
||||
page.locator(sheet).evaluate("node => node.classList.add('open')")
|
||||
control = page.locator(button)
|
||||
expect(control).to_be_visible()
|
||||
expect(control).to_have_text(expected)
|
||||
assert control.bounding_box()["height"] >= 44
|
||||
control.focus()
|
||||
expect(control).to_be_focused()
|
||||
page.locator(sheet).evaluate("node => node.classList.remove('open')")
|
||||
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
browser.close()
|
||||
|
|
@ -72,7 +72,8 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
|
|||
"tests/e2e/test_mobile_week_ahead_release.py "
|
||||
"tests/e2e/test_mobile_today_week_reschedule_release.py "
|
||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py "
|
||||
"tests/e2e/test_mobile_following_release.py -q"
|
||||
"tests/e2e/test_mobile_following_release.py "
|
||||
"tests/e2e/test_mobile_detail_watch_release.py -q"
|
||||
) in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
|
||||
|
|
|
|||
186
tests/test_detail_watch.py
Normal file
186
tests/test_detail_watch.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
MODULE = ROOT / "frontend" / "search-preview.js"
|
||||
|
||||
|
||||
def run(script: str) -> dict:
|
||||
harness = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const createDetailWatch = globalThis.createDetailWatch;
|
||||
const state = {{ requests:[], renders:[], refreshes:0 }};
|
||||
const feature = createDetailWatch({{
|
||||
fetchJson: async (path, options={{}}) => {{
|
||||
state.requests.push({{path,method:options.method || 'GET'}});
|
||||
return {{watching:false,following_synced:true}};
|
||||
}},
|
||||
refreshFollowing: async () => {{ state.refreshes += 1; }},
|
||||
onState: (status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_detail_watch_loads_authoritative_typed_subscription_state():
|
||||
result = run("""
|
||||
await feature.open({repository:'stackchain/api',kind:'pull',number:84,state:'open'});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["requests"] == [{
|
||||
"path": "api/v1/repos/stackchain/api/issues/84/preview/subscription?kind=pull",
|
||||
"method": "GET",
|
||||
}]
|
||||
assert result["renders"] == [
|
||||
{"status": "loading", "watching": False},
|
||||
{"status": "ready", "watching": False},
|
||||
]
|
||||
assert result["refreshes"] == 0
|
||||
|
||||
|
||||
def test_detail_watch_mutation_is_single_flight_and_refreshes_following_after_confirmation():
|
||||
script = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const createDetailWatch = globalThis.createDetailWatch;
|
||||
const state={{requests:[],renders:[],refreshes:0}};
|
||||
let finishMutation;
|
||||
const feature=createDetailWatch({{
|
||||
fetchJson:async (path,options={{}}) => {{
|
||||
state.requests.push({{path,method:options.method || 'GET'}});
|
||||
if (!options.method) return {{watching:false}};
|
||||
return new Promise(resolve => {{finishMutation=() => resolve({{watching:true,following_synced:true,following_count:3}});}});
|
||||
}},
|
||||
refreshFollowing:async () => {{state.refreshes += 1;}},
|
||||
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{
|
||||
const item={{repository:'stackchain/api',kind:'issue',number:42,state:'open'}};
|
||||
await feature.open(item);
|
||||
const first=feature.toggle();
|
||||
const second=feature.toggle();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
state.samePromise=first === second;
|
||||
finishMutation();
|
||||
await Promise.all([first,second]);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["samePromise"] is True
|
||||
assert [request["method"] for request in result["requests"]] == ["GET", "PUT"]
|
||||
assert result["refreshes"] == 1
|
||||
assert result["renders"][-2]["status"] == "watching"
|
||||
assert result["renders"][-1]["status"] == "watched"
|
||||
assert result["renders"][-1]["watching"] is True
|
||||
|
||||
|
||||
def test_detail_watch_partial_sync_never_claims_success_or_refreshes_following():
|
||||
script = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const createDetailWatch = globalThis.createDetailWatch;
|
||||
const state={{renders:[],refreshes:0}};
|
||||
const feature=createDetailWatch({{
|
||||
fetchJson:async (_path,options={{}}) => options.method
|
||||
? {{watching:true,following_synced:false,error:'Following could not sync.'}}
|
||||
: {{watching:false}},
|
||||
refreshFollowing:async () => {{state.refreshes += 1;}},
|
||||
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.open({{repository:'stackchain/api',kind:'issue',number:42,state:'open'}});
|
||||
try {{ await feature.toggle(); }} catch (error) {{ state.rejected=error.message; }}
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["rejected"] == "Following could not sync."
|
||||
assert result["refreshes"] == 0
|
||||
assert result["renders"][-1]["status"] == "error"
|
||||
assert result["renders"][-1]["watching"] is False
|
||||
assert all(state["status"] != "watched" for state in result["renders"])
|
||||
|
||||
|
||||
def test_detail_watch_is_wired_into_both_mobile_work_details_and_bundle():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
||||
workflow = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text()
|
||||
search_preview = (ROOT / "frontend" / "search-preview.js").read_text()
|
||||
|
||||
assert '<button id="watch-issue-detail" type="button">Watch issue</button>' in html
|
||||
assert '<button id="watch-pull-detail" type="button">Watch pull request</button>' in html
|
||||
assert 'id="issue-watch-status"' in html
|
||||
assert 'id="pull-watch-status"' in html
|
||||
assert 'static/search-preview.js' in bundle
|
||||
assert 'root.createDetailWatch' in search_preview
|
||||
assert 'function bindDetailWatch(' in dashboard
|
||||
assert 'issueDetailWatch.open(item)' in dashboard
|
||||
assert 'pullDetailWatch.open(item)' in dashboard
|
||||
assert '#watch-issue-detail' in css
|
||||
assert '#watch-pull-detail' in css
|
||||
assert 'tests/e2e/test_mobile_detail_watch_release.py' in workflow
|
||||
|
||||
|
||||
def test_detail_watch_ignores_a_late_subscription_response_for_the_previous_item():
|
||||
script = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const createDetailWatch = globalThis.createDetailWatch;
|
||||
const state={{renders:[]}};
|
||||
const completions={{}};
|
||||
const feature=createDetailWatch({{
|
||||
fetchJson:path => new Promise(resolve => {{completions[path]=resolve;}}),
|
||||
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{
|
||||
const first=feature.open({{repository:'stackchain/api',kind:'issue',number:1,state:'open'}});
|
||||
const second=feature.open({{repository:'stackchain/api',kind:'issue',number:2,state:'open'}});
|
||||
completions[Object.keys(completions)[1]]({{watching:true}});
|
||||
await second;
|
||||
completions[Object.keys(completions)[0]]({{watching:false}});
|
||||
await first;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
ready = [state for state in result["renders"] if state["status"] == "ready"]
|
||||
assert len(ready) == 1
|
||||
assert ready[0]["watching"] is True
|
||||
|
||||
|
||||
def test_detail_watch_reports_subscription_load_failure_as_recoverable_error():
|
||||
script = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const state={{renders:[]}};
|
||||
const feature=globalThis.createDetailWatch({{
|
||||
fetchJson:async () => {{throw new Error('temporarily unavailable');}},
|
||||
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{
|
||||
try {{ await feature.open({{repository:'stackchain/api',kind:'issue',number:1,state:'open'}}); }}
|
||||
catch (error) {{ state.rejected=error.message; }}
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["rejected"] == "temporarily unavailable"
|
||||
assert result["renders"][-1] == {
|
||||
"status": "error", "watching": False, "error": "temporarily unavailable"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user