feat: review watched pull request changes (Closes #1328)
This commit is contained in:
parent
e39e92b025
commit
df90fb1e0e
|
|
@ -1042,6 +1042,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||||
.search-preview-comment.new-since-review { padding-left:10px; border-left:3px solid var(--accent); background:#10233a; }
|
.search-preview-comment.new-since-review { padding-left:10px; border-left:3px solid var(--accent); background:#10233a; }
|
||||||
.search-preview-conversation button { min-height:44px; width:100%; }
|
.search-preview-conversation button { min-height:44px; width:100%; }
|
||||||
|
.search-preview-review { display:grid; gap:8px; min-width:0; padding-top:8px; border-top:1px solid #2a496e; }
|
||||||
|
.search-preview-review[hidden] { display:none; }
|
||||||
|
.search-preview-review h2 { margin:0; font-size:1rem; }
|
||||||
|
.search-preview-review button { min-height:44px; width:100%; }
|
||||||
|
.search-preview-file { min-width:0; margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; overflow:hidden; }
|
||||||
|
.search-preview-file > strong, .search-preview-file > .small { display:block; overflow-wrap:anywhere; }
|
||||||
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||||
.search-preview-reply[hidden] { display:none; }
|
.search-preview-reply[hidden] { display:none; }
|
||||||
.search-preview-reply h2 { margin:0; font-size:1rem; }
|
.search-preview-reply h2 { margin:0; font-size:1rem; }
|
||||||
|
|
@ -1083,7 +1089,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
padding-bottom:calc(6px + env(safe-area-inset-bottom));
|
padding-bottom:calc(6px + env(safe-area-inset-bottom));
|
||||||
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
|
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
|
||||||
}
|
}
|
||||||
#search-preview-overview, #search-preview-conversation,
|
#search-preview-overview, #search-preview-conversation, #search-preview-review,
|
||||||
#search-preview-reply-workspace, #search-preview-actions { scroll-margin-top:72px; }
|
#search-preview-reply-workspace, #search-preview-actions { scroll-margin-top:72px; }
|
||||||
#search-preview-actions { position:static; }
|
#search-preview-actions { position:static; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5693,7 +5693,7 @@
|
||||||
watchButton.hidden = true;
|
watchButton.hidden = true;
|
||||||
watchButton.disabled = false;
|
watchButton.disabled = false;
|
||||||
shareButton.disabled = true;
|
shareButton.disabled = true;
|
||||||
renderSearchPreviewReply(state, null, searchPreview, document);
|
renderSearchPreviewWorkspaces(state,null,searchPreview,document,escapeHtml);
|
||||||
|
|
||||||
if (state.status === 'loading') {
|
if (state.status === 'loading') {
|
||||||
searchReplyAttachmentTarget = { ...state.item };
|
searchReplyAttachmentTarget = { ...state.item };
|
||||||
|
|
@ -5727,7 +5727,7 @@
|
||||||
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
|
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
|
||||||
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
||||||
renderSearchConversation(state.conversation);
|
renderSearchConversation(state.conversation);
|
||||||
renderSearchPreviewReply(state, detail, searchPreview, document);
|
renderSearchPreviewWorkspaces(state,detail,searchPreview,document,escapeHtml);
|
||||||
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
|
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
|
||||||
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
|
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
|
||||||
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
||||||
|
|
@ -5766,13 +5766,10 @@
|
||||||
const searchSubscription = searchPreviewSubscriptionOptions(fetchReviewJson);
|
const searchSubscription = searchPreviewSubscriptionOptions(fetchReviewJson);
|
||||||
const searchPreview = createSearchPreview({
|
const searchPreview = createSearchPreview({
|
||||||
fetchJson:searchSubscription.preview,
|
fetchJson:searchSubscription.preview,
|
||||||
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page),{
|
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page)),
|
||||||
headers:{Accept:'application/json'},
|
fetchReview:searchSubscription.review,
|
||||||
}),
|
mutate:(detail,action)=>fetchReviewJson(
|
||||||
mutate: (detail, action) => fetchReviewJson(
|
searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'}
|
||||||
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
|
|
||||||
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
|
||||||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
|
||||||
),
|
),
|
||||||
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
|
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
|
||||||
followingQueue.load().catch(() => {}).then(() => result)),
|
followingQueue.load().catch(() => {}).then(() => result)),
|
||||||
|
|
|
||||||
|
|
@ -886,6 +886,7 @@
|
||||||
<nav class="mobile-search-preview-nav" aria-label="Search preview sections">
|
<nav class="mobile-search-preview-nav" aria-label="Search preview sections">
|
||||||
<button type="button" data-search-preview-section="overview">Overview</button>
|
<button type="button" data-search-preview-section="overview">Overview</button>
|
||||||
<button type="button" data-search-preview-section="conversation">Conversation</button>
|
<button type="button" data-search-preview-section="conversation">Conversation</button>
|
||||||
|
<button type="button" data-search-preview-section="changes" hidden>Changes</button>
|
||||||
<button type="button" data-search-preview-section="reply">Reply</button>
|
<button type="button" data-search-preview-section="reply">Reply</button>
|
||||||
<button type="button" data-search-preview-section="actions">Act</button>
|
<button type="button" data-search-preview-section="actions">Act</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -901,6 +902,12 @@
|
||||||
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
|
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
|
||||||
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
|
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="search-preview-review" class="search-preview-review" aria-labelledby="search-preview-review-title" hidden>
|
||||||
|
<h2 id="search-preview-review-title">Changes</h2>
|
||||||
|
<div id="search-preview-review-status" class="small" aria-live="polite"></div>
|
||||||
|
<div id="search-preview-files"></div>
|
||||||
|
<button id="retry-search-preview-review" type="button" hidden>Retry changes</button>
|
||||||
|
</section>
|
||||||
<section class="search-preview-reply" id="search-preview-reply-workspace" aria-labelledby="search-preview-reply-title" hidden>
|
<section class="search-preview-reply" id="search-preview-reply-workspace" aria-labelledby="search-preview-reply-title" hidden>
|
||||||
<h2 id="search-preview-reply-title">Reply</h2>
|
<h2 id="search-preview-reply-title">Reply</h2>
|
||||||
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||||
|
|
|
||||||
|
|
@ -98,12 +98,14 @@ function attachMobileSearchPreviewNavigation({document, window}) {
|
||||||
buttons:{
|
buttons:{
|
||||||
overview:bySection('overview'),
|
overview:bySection('overview'),
|
||||||
conversation:bySection('conversation'),
|
conversation:bySection('conversation'),
|
||||||
|
changes:bySection('changes'),
|
||||||
reply:bySection('reply'),
|
reply:bySection('reply'),
|
||||||
actions:bySection('actions'),
|
actions:bySection('actions'),
|
||||||
},
|
},
|
||||||
targets:{
|
targets:{
|
||||||
overview:document.getElementById('search-preview-overview'),
|
overview:document.getElementById('search-preview-overview'),
|
||||||
conversation:document.getElementById('search-preview-conversation'),
|
conversation:document.getElementById('search-preview-conversation'),
|
||||||
|
changes:document.getElementById('search-preview-review'),
|
||||||
reply,
|
reply,
|
||||||
actions:document.getElementById('search-preview-actions'),
|
actions:document.getElementById('search-preview-actions'),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@
|
||||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||||
'/preview/conversation?' + query.toString();
|
'/preview/conversation?' + query.toString();
|
||||||
};
|
};
|
||||||
|
root.followingPullReviewPath = item => {
|
||||||
|
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||||
|
return 'api/v1/following/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review-data';
|
||||||
|
};
|
||||||
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||||
'/comments?kind=' + encodeURIComponent(item.kind);
|
'/comments?kind=' + encodeURIComponent(item.kind);
|
||||||
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||||
|
|
@ -47,6 +51,7 @@
|
||||||
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
|
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
|
||||||
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
|
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
|
||||||
}),
|
}),
|
||||||
|
review:item => fetchJson(root.followingPullReviewPath(item)),
|
||||||
};
|
};
|
||||||
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
|
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
|
||||||
headers:{Accept:'application/json'},
|
headers:{Accept:'application/json'},
|
||||||
|
|
@ -165,9 +170,58 @@
|
||||||
else if (state.status === 'reply-error') status.textContent =
|
else if (state.status === 'reply-error') status.textContent =
|
||||||
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
|
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
|
||||||
};
|
};
|
||||||
|
root.renderSearchPreviewReview = (review, document, escapeHtml) => {
|
||||||
|
const section = document.querySelector('#search-preview-review');
|
||||||
|
const status = document.querySelector('#search-preview-review-status');
|
||||||
|
const files = document.querySelector('#search-preview-files');
|
||||||
|
const retry = document.querySelector('#retry-search-preview-review');
|
||||||
|
section.hidden = !review;
|
||||||
|
retry.hidden = review?.status !== 'error';
|
||||||
|
files.innerHTML = '';
|
||||||
|
if (!review) {
|
||||||
|
status.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (review.status === 'loading') {
|
||||||
|
status.textContent = 'Loading CI and changed files…';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (review.status === 'error') {
|
||||||
|
status.textContent = 'Changes unavailable. This revision has not been marked reviewed.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = review.data || {};
|
||||||
|
const changed = Array.isArray(data.files) ? data.files : [];
|
||||||
|
const ci = ({success:'CI passed', failure:'CI failed', error:'CI failed', pending:'CI pending'})[
|
||||||
|
data.ci_state
|
||||||
|
] || 'CI status unavailable';
|
||||||
|
status.textContent = ci + ' · ' + changed.length + ' changed ' +
|
||||||
|
(changed.length === 1 ? 'file.' : 'files.');
|
||||||
|
files.innerHTML = changed.map(file => {
|
||||||
|
const lines = (Array.isArray(file.diff_lines) ? file.diff_lines : []).map(raw => {
|
||||||
|
const line = String(raw);
|
||||||
|
const kind = line.startsWith('@@') ? 'hunk' : line.startsWith('+') ? 'added' :
|
||||||
|
line.startsWith('-') ? 'removed' : 'context';
|
||||||
|
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(line) + '</span>';
|
||||||
|
}).join('');
|
||||||
|
const diff = file.diff_available
|
||||||
|
? '<pre class="pull-diff">' + lines +
|
||||||
|
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
|
||||||
|
: '<div class="pull-diff-empty">' +
|
||||||
|
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') + '</div>';
|
||||||
|
return '<article class="search-preview-file"><strong>' + escapeHtml(file.filename || 'Unknown file') +
|
||||||
|
'</strong><span class="small">' + escapeHtml(file.status || 'changed') + ' · +' +
|
||||||
|
Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span>' + diff + '</article>';
|
||||||
|
}).join('');
|
||||||
|
};
|
||||||
|
root.renderSearchPreviewWorkspaces = (state, detail, preview, document, escapeHtml) => {
|
||||||
|
root.renderSearchPreviewReply(state, detail, preview, document);
|
||||||
|
root.renderSearchPreviewReview(state.review, document, escapeHtml);
|
||||||
|
document.querySelector('[data-search-preview-section="changes"]').hidden = !state.review;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
|
return function createSearchPreview({ fetchJson, fetchConversation, fetchReview, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
|
||||||
if (Array.isArray(session)) {
|
if (Array.isArray(session)) {
|
||||||
getSession = session[0];
|
getSession = session[0];
|
||||||
loadMore = () => session[1].loadMore();
|
loadMore = () => session[1].loadMore();
|
||||||
|
|
@ -182,6 +236,7 @@
|
||||||
let replyRequest = null;
|
let replyRequest = null;
|
||||||
let watchRequest = null;
|
let watchRequest = null;
|
||||||
let conversation = null;
|
let conversation = null;
|
||||||
|
let review = null;
|
||||||
let openedRevision = null;
|
let openedRevision = null;
|
||||||
|
|
||||||
function sameItem(left, right) {
|
function sameItem(left, right) {
|
||||||
|
|
@ -228,6 +283,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function publish(state) {
|
function publish(state) {
|
||||||
|
if (conversation && !Object.prototype.hasOwnProperty.call(state, 'conversation')) {
|
||||||
|
state = {...state, conversation};
|
||||||
|
}
|
||||||
|
if (review && !Object.prototype.hasOwnProperty.call(state, 'review')) state = {...state, review};
|
||||||
const position = navigation(state.item || current);
|
const position = navigation(state.item || current);
|
||||||
if (navigationRoot) {
|
if (navigationRoot) {
|
||||||
const bar = navigationRoot.querySelector('.search-preview-navigation');
|
const bar = navigationRoot.querySelector('.search-preview-navigation');
|
||||||
|
|
@ -293,6 +352,37 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadReview(detail, requestGeneration) {
|
||||||
|
if (!(detail?.following === true && detail?.kind === 'pull' && typeof fetchReview === 'function')) {
|
||||||
|
review = null;
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
review = {status:'loading', data:null};
|
||||||
|
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||||
|
return fetchReview(detail).then(data => {
|
||||||
|
if (requestGeneration === generation) {
|
||||||
|
review = {status:'ready', data};
|
||||||
|
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}).catch(error => {
|
||||||
|
if (requestGeneration === generation) {
|
||||||
|
review = {status:'error', data:null, error};
|
||||||
|
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acknowledgeWhenContextReady(requestGeneration) {
|
||||||
|
const conversationReady = typeof fetchConversation !== 'function' || conversation?.status === 'ready';
|
||||||
|
const reviewRequired = current?.following === true && current?.kind === 'pull' &&
|
||||||
|
typeof fetchReview === 'function';
|
||||||
|
if (conversationReady && (!reviewRequired || review?.status === 'ready')) {
|
||||||
|
await notifyOpened(current, requestGeneration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
hasReplyAttachments() {
|
hasReplyAttachments() {
|
||||||
return Boolean(hasAttachments?.());
|
return Boolean(hasAttachments?.());
|
||||||
|
|
@ -303,6 +393,7 @@
|
||||||
const requestGeneration = generation;
|
const requestGeneration = generation;
|
||||||
current = { ...item };
|
current = { ...item };
|
||||||
conversation = null;
|
conversation = null;
|
||||||
|
review = null;
|
||||||
openedRevision = null;
|
openedRevision = null;
|
||||||
publish({ status: 'loading', item: current });
|
publish({ status: 'loading', item: current });
|
||||||
return fetchJson(current).then(async detail => {
|
return fetchJson(current).then(async detail => {
|
||||||
|
|
@ -311,8 +402,9 @@
|
||||||
if (typeof fetchConversation === 'function') {
|
if (typeof fetchConversation === 'function') {
|
||||||
const context = loadConversation(current, requestGeneration);
|
const context = loadConversation(current, requestGeneration);
|
||||||
if (current.following === true) {
|
if (current.following === true) {
|
||||||
await context;
|
const reviewContext = loadReview(current, requestGeneration);
|
||||||
if (conversation?.status === 'ready') await notifyOpened(current, requestGeneration);
|
await Promise.all([context, reviewContext]);
|
||||||
|
await acknowledgeWhenContextReady(requestGeneration);
|
||||||
} else {
|
} else {
|
||||||
await notifyOpened(current, requestGeneration);
|
await notifyOpened(current, requestGeneration);
|
||||||
}
|
}
|
||||||
|
|
@ -371,9 +463,14 @@
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
const requestGeneration = generation;
|
const requestGeneration = generation;
|
||||||
const result = await loadConversation(current, requestGeneration);
|
const result = await loadConversation(current, requestGeneration);
|
||||||
if (current?.following === true && conversation?.status === 'ready') {
|
if (current?.following === true) await acknowledgeWhenContextReady(requestGeneration);
|
||||||
await notifyOpened(current, requestGeneration);
|
return result;
|
||||||
}
|
},
|
||||||
|
async retryReview() {
|
||||||
|
if (!current) return null;
|
||||||
|
const requestGeneration = generation;
|
||||||
|
const result = await loadReview(current, requestGeneration);
|
||||||
|
await acknowledgeWhenContextReady(requestGeneration);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
loadOlderConversation() {
|
loadOlderConversation() {
|
||||||
|
|
@ -482,6 +579,7 @@
|
||||||
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
|
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
|
||||||
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
|
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
|
||||||
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
|
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
|
||||||
|
navigationRoot.querySelector('#retry-search-preview-review').addEventListener('click', () => api.retryReview());
|
||||||
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
|
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
|
||||||
const reply = navigationRoot.querySelector('#search-preview-reply');
|
const reply = navigationRoot.querySelector('#search-preview-reply');
|
||||||
reply?.addEventListener('input', event => {
|
reply?.addEventListener('input', event => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/private-data-registry.js');
|
importScripts(BASE + 'static/private-data-registry.js');
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v137';
|
const CACHE = 'stackchain-dashboard-shell-v138';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
|
||||||
39
src/main.py
39
src/main.py
|
|
@ -2753,6 +2753,45 @@ async def keep_following_revision(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/following/{owner}/{repo}/pulls/{number}/review-data")
|
||||||
|
async def following_pull_review_data(
|
||||||
|
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||||
|
):
|
||||||
|
"""Return bounded review context only for a pull in this account's Following list."""
|
||||||
|
repository = f"{owner}/{repo}"
|
||||||
|
login = await _confirmed_login()
|
||||||
|
|
||||||
|
async def load_review():
|
||||||
|
snapshot = await asyncio.to_thread(_following_store().get, login)
|
||||||
|
watched = any(
|
||||||
|
item.get("kind") == "pull"
|
||||||
|
and item.get("repository", "").lower() == repository.lower()
|
||||||
|
and item.get("number") == number
|
||||||
|
for item in snapshot["items"]
|
||||||
|
)
|
||||||
|
if not watched:
|
||||||
|
raise HTTPException(status_code=404, detail="Watched pull request not found")
|
||||||
|
return await gitea_proxy.pull_completion_review(repository, number)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(load_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except TimeoutError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Loading watched pull request changes timed out. Please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Watched pull request changes are temporarily unavailable. Please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/completed-filed-reviews")
|
@app.get("/api/v1/completed-filed-reviews")
|
||||||
async def get_completed_filed_reviews(response: Response):
|
async def get_completed_filed_reviews(response: Response):
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,29 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
|
||||||
assert page.locator(".following-card").bounding_box()["height"] >= 44
|
assert page.locator(".following-card").bounding_box()["height"] >= 44
|
||||||
assert page.locator("#close-following").bounding_box()["height"] >= 44
|
assert page.locator("#close-following").bounding_box()["height"] >= 44
|
||||||
|
|
||||||
|
page.evaluate("""() => {
|
||||||
|
document.querySelector('#following-sheet').close();
|
||||||
|
document.querySelector('#search-preview').classList.add('open');
|
||||||
|
renderSearchPreviewReview({status:'ready',data:{ci_state:'success',files:[{
|
||||||
|
filename:'src/a/very/long/mobile/path/that-must-wrap/review.py',status:'modified',
|
||||||
|
additions:1,deletions:1,diff_available:true,
|
||||||
|
diff_lines:['@@ -1 +1 @@','-unsafe long line that scrolls inside the diff only',
|
||||||
|
'+safe long line that scrolls inside the diff only']
|
||||||
|
}]}},document,value=>String(value));
|
||||||
|
document.querySelector('[data-search-preview-section="changes"]').hidden=false;
|
||||||
|
}""")
|
||||||
|
changes = page.locator("#search-preview-review")
|
||||||
|
expect(changes).to_be_visible()
|
||||||
|
expect(changes).to_contain_text("CI passed · 1 changed file.")
|
||||||
|
expect(changes).to_contain_text("review.py")
|
||||||
|
assert page.locator('[data-search-preview-section="changes"]').bounding_box()["height"] >= 44
|
||||||
|
assert page.locator("#search-preview-review .pull-diff").evaluate(
|
||||||
|
"node => node.scrollWidth >= node.clientWidth"
|
||||||
|
) is True
|
||||||
|
assert page.evaluate(
|
||||||
|
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||||
|
) is False
|
||||||
|
|
||||||
retire = page.locator("#watch-search-result")
|
retire = page.locator("#watch-search-result")
|
||||||
page.evaluate("""() => {
|
page.evaluate("""() => {
|
||||||
document.querySelector('#following-sheet').close();
|
document.querySelector('#following-sheet').close();
|
||||||
|
|
|
||||||
|
|
@ -1334,7 +1334,7 @@ def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
|
||||||
html = dashboard_bundle_text()
|
html = dashboard_bundle_text()
|
||||||
|
|
||||||
assert "detail.reopenable ? 'Reopen & resume'" in html
|
assert "detail.reopenable ? 'Reopen & resume'" in html
|
||||||
assert "mutate: (detail, action) => fetchReviewJson(" in html
|
assert "mutate:(detail,action)=>fetchReviewJson(" in html
|
||||||
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
|
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
|
||||||
"qs('#close-whiteboard')", 1
|
"qs('#close-whiteboard')", 1
|
||||||
)[0]
|
)[0]
|
||||||
|
|
|
||||||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
assert "stackchain-dashboard-shell-v137" in worker
|
assert "stackchain-dashboard-shell-v138" in worker
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,63 @@ async def test_confirmed_pull_watch_round_trips_through_following(monkeypatch, t
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_watched_pull_can_load_read_only_review_context_without_assignment(monkeypatch, tmp_path):
|
||||||
|
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"r" * 32)
|
||||||
|
store.set_watching("timmy", {
|
||||||
|
"repository": "stackchain/api", "kind": "pull", "number": 84,
|
||||||
|
"title": "Review watched changes", "state": "open",
|
||||||
|
"updated_at": "2026-08-24T05:00:00Z",
|
||||||
|
"url": "https://forge.example/stackchain/api/pulls/84",
|
||||||
|
}, True)
|
||||||
|
review = {
|
||||||
|
"head_sha": "abc123", "ci_state": "success",
|
||||||
|
"files": [{"filename": "src/api.py", "status": "modified", "patch": "+safe"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
async def load_review(repository, number):
|
||||||
|
assert (repository, number) == ("stackchain/api", 84)
|
||||||
|
return review
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", load_review)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/following/stackchain/api/pulls/84/review-data"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert response.json() == review
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_unwatched_pull_review_context_is_not_exposed(monkeypatch, tmp_path):
|
||||||
|
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"u" * 32)
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
async def unexpected_review(*_args):
|
||||||
|
raise AssertionError("unwatched pull must be rejected before upstream access")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", unexpected_review)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/following/stackchain/api/pulls/84/review-data"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path):
|
async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path):
|
||||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1)
|
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1)
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,76 @@ const preview=createSearchPreview({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_following_pull_revision_waits_for_changes_and_ci_context():
|
||||||
|
preview_module = ROOT / "frontend" / "search-preview.js"
|
||||||
|
script = f"""
|
||||||
|
const createSearchPreview=require({json.dumps(str(preview_module))});
|
||||||
|
let unavailable=true;
|
||||||
|
const opened=[];
|
||||||
|
const states=[];
|
||||||
|
const preview=createSearchPreview({{
|
||||||
|
fetchJson:async item=>({{...item,title:'Changed pull request'}}),
|
||||||
|
fetchConversation:async()=>({{comments:[]}}),
|
||||||
|
fetchReview:async()=>{{if(unavailable)throw new Error('diff offline');return {{ci_state:'success',files:[{{filename:'src/api.py',diff_lines:['+safe'],diff_available:true}}]}};}},
|
||||||
|
onOpened:async item=>opened.push(item.updated_at),
|
||||||
|
onState:state=>states.push({{status:state.review?.status,ci:state.review?.data?.ci_state}}),
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
await preview.open({{
|
||||||
|
repository:'stackchain/api',kind:'pull',number:84,following:true,
|
||||||
|
reviewed_at:'2026-08-23T03:00:00Z',updated_at:'2026-08-23T04:00:00Z'
|
||||||
|
}});
|
||||||
|
const afterFailure=[...opened];
|
||||||
|
unavailable=false;
|
||||||
|
await preview.retryReview();
|
||||||
|
process.stdout.write(JSON.stringify({{afterFailure,opened,last:states.at(-1)}}));
|
||||||
|
}})().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 == {
|
||||||
|
"afterFailure": [],
|
||||||
|
"opened": ["2026-08-23T04:00:00Z"],
|
||||||
|
"last": {"status": "ready", "ci": "success"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_watched_pull_changes_render_bounded_diff_and_ci_state():
|
||||||
|
preview_module = ROOT / "frontend" / "search-preview.js"
|
||||||
|
script = f"""
|
||||||
|
require({json.dumps(str(preview_module))});
|
||||||
|
const nodes={{
|
||||||
|
'#search-preview-review':{{hidden:true}},
|
||||||
|
'#search-preview-review-status':{{textContent:''}},
|
||||||
|
'#search-preview-files':{{innerHTML:''}},
|
||||||
|
'#retry-search-preview-review':{{hidden:true}},
|
||||||
|
}};
|
||||||
|
const document={{querySelector:selector=>nodes[selector]}};
|
||||||
|
global.renderSearchPreviewReview({{status:'ready',data:{{ci_state:'success',files:[{{
|
||||||
|
filename:'src/api.py',status:'modified',additions:1,deletions:0,
|
||||||
|
diff_available:true,diff_lines:['@@ -1 +1 @@','-unsafe','+safe']
|
||||||
|
}}]}}}},document,value=>String(value).replaceAll('<','<'));
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
hidden:nodes['#search-preview-review'].hidden,
|
||||||
|
status:nodes['#search-preview-review-status'].textContent,
|
||||||
|
html:nodes['#search-preview-files'].innerHTML,
|
||||||
|
retry:nodes['#retry-search-preview-review'].hidden,
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
result = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], text=True, capture_output=True, check=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert result["hidden"] is False
|
||||||
|
assert result["status"] == "CI passed · 1 changed file."
|
||||||
|
assert "src/api.py" in result["html"]
|
||||||
|
assert "pull-diff-line added" in result["html"]
|
||||||
|
assert result["retry"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_following_typed_identity_prevents_issue_pull_collisions():
|
def test_following_typed_identity_prevents_issue_pull_collisions():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createFollowing = require({json.dumps(str(MODULE))});
|
const createFollowing = require({json.dumps(str(MODULE))});
|
||||||
|
|
@ -523,7 +593,7 @@ process.stdout.write(JSON.stringify({{
|
||||||
assert ".following-disposition-mode" in css
|
assert ".following-disposition-mode" in css
|
||||||
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
||||||
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
||||||
assert "stackchain-dashboard-shell-v137" in service_worker
|
assert "stackchain-dashboard-shell-v138" in service_worker
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||||
|
|
|
||||||
|
|
@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/later-sync.js'" in source
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v137" in worker
|
assert "stackchain-dashboard-shell-v138" in worker
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||||
|
|
||||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v137" in worker
|
assert "stackchain-dashboard-shell-v138" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert "controller.recoverPermission('deadline')" in dashboard
|
assert "controller.recoverPermission('deadline')" in dashboard
|
||||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||||
assert "stackchain-dashboard-shell-v137" in worker
|
assert "stackchain-dashboard-shell-v138" in worker
|
||||||
assert ".device-setup-panel" in css
|
assert ".device-setup-panel" in css
|
||||||
assert ".device-readiness-card" in css
|
assert ".device-readiness-card" in css
|
||||||
assert "overflow-x:hidden" in css
|
assert "overflow-x:hidden" in css
|
||||||
|
|
|
||||||
|
|
@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in worker
|
assert "stackchain-dashboard-shell-v138" in worker
|
||||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||||
|
|
|
||||||
|
|
@ -414,7 +414,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||||
assert "stackchain-dashboard-shell-v137" in service_worker
|
assert "stackchain-dashboard-shell-v138" in service_worker
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
|
||||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ async function dispatchPush(payload) {{
|
||||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/week-plan.js'" in source
|
assert "BASE + 'static/week-plan.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
||||||
|
|
@ -197,20 +197,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
|
||||||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
|
|
||||||
|
|
||||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/week-plan.js'" in source
|
assert "BASE + 'static/week-plan.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/my-work.js'" in source
|
assert "BASE + 'static/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
@ -219,7 +219,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/authored-outbox.js'" in source
|
assert "BASE + 'static/authored-outbox.js'" in source
|
||||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||||
|
|
@ -228,7 +228,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||||
assert "BASE + 'static/issue-attachment.js'" in source
|
assert "BASE + 'static/issue-attachment.js'" in source
|
||||||
|
|
||||||
|
|
@ -236,14 +236,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/today-completion.js'" in source
|
assert "BASE + 'static/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -251,7 +251,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -259,7 +259,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/issue-sheet.js'" in source
|
assert "BASE + 'static/issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
@ -269,14 +269,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/later-picker.js'" in source
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -285,21 +285,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1354,7 +1354,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/queue-today.js'" in source
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||||
def test_readiness_runtime_is_available_in_offline_shell():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
service_worker = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "const CACHE = 'stackchain-dashboard-shell-v137';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v138';" in service_worker
|
||||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v137" in source
|
assert "stackchain-dashboard-shell-v138" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user