Merge pull request 'Keep Create & start durable across interruptions' (#390) from timmy/389-durable-create-and-start into main
This commit is contained in:
commit
bf4ce3adad
|
|
@ -180,7 +180,10 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||||
claim,
|
claim,
|
||||||
claimNext,
|
claimNext,
|
||||||
claimBatch,
|
claimBatch,
|
||||||
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
|
complete: (id, deliveredIssue) => update(id, item => ({
|
||||||
|
...item, status: 'sent', claimUntil: 0,
|
||||||
|
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
|
||||||
|
})),
|
||||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||||
fail: (id, error, deliveryState) => update(id, item => ({
|
fail: (id, error, deliveryState) => update(id, item => ({
|
||||||
...item, status: 'attention', claimUntil: 0, error,
|
...item, status: 'attention', claimUntil: 0, error,
|
||||||
|
|
@ -303,7 +306,7 @@ function createBackgroundIssueSync({
|
||||||
const request = deliveryRequest(item);
|
const request = deliveryRequest(item);
|
||||||
try {
|
try {
|
||||||
const delivered = await requestJson(request.url, request.options);
|
const delivered = await requestJson(request.url, request.options);
|
||||||
await store.complete(item.id);
|
await store.complete(item.id, delivered);
|
||||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,11 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
|
||||||
announce('Created, but Today could not be saved on this device.');
|
announce('Created, but Today could not be saved on this device.');
|
||||||
return 'unavailable';
|
return 'unavailable';
|
||||||
}
|
}
|
||||||
completed.add(identity);
|
if (!todaySync.enqueue('add', identity)) {
|
||||||
if (added === 'added' && !todaySync.enqueue('add', identity)) {
|
|
||||||
announce('Created and saved to Today on this device, but account sync is unavailable.');
|
announce('Created and saved to Today on this device, but account sync is unavailable.');
|
||||||
return 'sync-unavailable';
|
return 'sync-unavailable';
|
||||||
}
|
}
|
||||||
|
completed.add(identity);
|
||||||
refresh();
|
refresh();
|
||||||
todaySync.flush();
|
todaySync.flush();
|
||||||
warm();
|
warm();
|
||||||
|
|
|
||||||
|
|
@ -1062,6 +1062,9 @@
|
||||||
const outboxActions = item.quarantined ?
|
const outboxActions = item.quarantined ?
|
||||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
||||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||||
|
item.continuation ?
|
||||||
|
'<button class="draft-continue" data-draft-index="' + index + '" type="button">Continue created work</button>' +
|
||||||
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Dismiss</button>' :
|
||||||
item.kind === 'issue-outbox' ?
|
item.kind === 'issue-outbox' ?
|
||||||
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
|
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
|
||||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
||||||
|
|
@ -1075,7 +1078,8 @@
|
||||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
||||||
const state = (isOutbox || isUnfiled) ?
|
const state = (isOutbox || isUnfiled) ?
|
||||||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||||||
(isUnfiled ? 'Needs filing' : item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
(isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' :
|
||||||
|
item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
||||||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||||||
return '<article class="my-work-card draft-card">' +
|
return '<article class="my-work-card draft-card">' +
|
||||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||||
|
|
@ -1100,6 +1104,16 @@
|
||||||
else if (item.route) workRoute.open(item.route);
|
else if (item.route) workRoute.open(item.route);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
list.querySelectorAll('.draft-continue').forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
|
const completion = issueOutbox.pendingCompletions(activeFlushLogin)
|
||||||
|
.find(candidate => candidate.id === item?.outbox_id);
|
||||||
|
if (completion) applyOutboxResult({
|
||||||
|
confirmed: [completion.issue], completions: [completion], remaining: issueOutbox.list(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
list.querySelectorAll('.draft-edit').forEach(button => {
|
list.querySelectorAll('.draft-edit').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
|
|
@ -1988,6 +2002,18 @@
|
||||||
});
|
});
|
||||||
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
|
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
|
const startedCompletions = new Set();
|
||||||
|
(result.completions || []).forEach(completion => {
|
||||||
|
if (completion.ownerLogin !== activeFlushLogin || completion.intent !== 'create-and-start') return;
|
||||||
|
const created = lastMyWork.find(item => item.kind === 'issue' &&
|
||||||
|
item.repository === completion.issue?.repository && item.number === completion.issue?.number);
|
||||||
|
if (!created) return;
|
||||||
|
const outcome = createAndStart.complete(created);
|
||||||
|
if (outcome === 'started' || outcome === 'exists') {
|
||||||
|
issueOutbox.completeIntent(completion.id, activeFlushLogin);
|
||||||
|
startedCompletions.add(created.repository + '#' + created.number);
|
||||||
|
}
|
||||||
|
});
|
||||||
if (result.confirmed?.length) {
|
if (result.confirmed?.length) {
|
||||||
const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', ');
|
const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', ');
|
||||||
qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
|
qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
|
||||||
|
|
@ -1995,10 +2021,11 @@
|
||||||
const created = lastMyWork.find(item =>
|
const created = lastMyWork.find(item =>
|
||||||
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
|
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
|
||||||
);
|
);
|
||||||
if (startCreated && created) {
|
const completionHandled = startedCompletions.has(confirmed.repository + '#' + confirmed.number);
|
||||||
|
if (startCreated && !(result.completions || []).length && created) {
|
||||||
const outcome = createAndStart.complete(created);
|
const outcome = createAndStart.complete(created);
|
||||||
if (outcome !== 'started' && openCreated) openRoutedWork(created, qs('#new-issue'));
|
if (outcome !== 'started' && openCreated) openRoutedWork(created, qs('#new-issue'));
|
||||||
} else if (openCreated && created) openRoutedWork(created, qs('#new-issue'));
|
} else if (openCreated && created && !completionHandled) openRoutedWork(created, qs('#new-issue'));
|
||||||
} else if ((result.remaining || []).some(item => item.status === 'attention')) {
|
} else if ((result.remaining || []).some(item => item.status === 'attention')) {
|
||||||
qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.';
|
qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.';
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2774,8 +2801,9 @@
|
||||||
qs('#create-issue-status').textContent = createAndStartRequested ?
|
qs('#create-issue-status').textContent = createAndStartRequested ?
|
||||||
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
|
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
|
||||||
try {
|
try {
|
||||||
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, captureDraft) :
|
const durableDraft = createAndStartRequested ? { ...captureDraft, completionIntent: 'create-and-start' } : captureDraft;
|
||||||
await issueOutbox.enqueueDurably(captureDraft);
|
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||||
|
await issueOutbox.enqueueDurably(durableDraft);
|
||||||
const queued = admission.item;
|
const queued = admission.item;
|
||||||
if (!admission.background) {
|
if (!admission.background) {
|
||||||
editingOutboxId = queued.id;
|
editingOutboxId = queued.id;
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||||
function parseOutbox(raw) {
|
function parseOutbox(raw) {
|
||||||
try {
|
try {
|
||||||
const record = JSON.parse(raw);
|
const record = JSON.parse(raw);
|
||||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||||
return record.items.filter(item =>
|
return record.items.filter(item =>
|
||||||
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
|
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
|
||||||
|
|
@ -112,9 +112,11 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
id: 'stackchain.issue-outbox.v1:' + item.id,
|
||||||
outbox_id: item.id,
|
outbox_id: item.id,
|
||||||
kind: 'issue-outbox',
|
kind: 'issue-outbox',
|
||||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
status: item.status === 'completion' ? 'completion' : (item.status === 'attention' ? 'attention' : 'queued'),
|
||||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue'),
|
(item.status === 'completion' ? 'Created · ready to start' :
|
||||||
|
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
|
||||||
|
continuation: item.status === 'completion' && item.completionIntent === 'create-and-start',
|
||||||
delivery_state: item.deliveryState,
|
delivery_state: item.deliveryState,
|
||||||
repository: item.repository,
|
repository: item.repository,
|
||||||
title: textPreview(item.title) || 'Untitled queued issue',
|
title: textPreview(item.title) || 'Untitled queued issue',
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
function read() {
|
function read() {
|
||||||
try {
|
try {
|
||||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||||
return record.items.filter(item => item && typeof item === 'object');
|
return record.items.filter(item => item && typeof item === 'object');
|
||||||
} catch (_error) { return []; }
|
} catch (_error) { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function write(items, mirror = true) {
|
function write(items, mirror = true) {
|
||||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
storage?.setItem(storageKey, JSON.stringify({ version: 3, items }));
|
||||||
coordinator?.notify('issue');
|
coordinator?.notify('issue');
|
||||||
if (mirror && backgroundSync?.reconcile) {
|
if (mirror && backgroundSync?.reconcile) {
|
||||||
Promise.resolve(backgroundSync.reconcile(items))
|
Promise.resolve(backgroundSync.reconcile(items))
|
||||||
|
|
@ -39,6 +39,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
queuedAt: Number(now()),
|
queuedAt: Number(now()),
|
||||||
};
|
};
|
||||||
|
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||||
item.operationId = item.id;
|
item.operationId = item.id;
|
||||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||||
item.milestoneId = Number(draft.milestoneId);
|
item.milestoneId = Number(draft.milestoneId);
|
||||||
|
|
@ -86,6 +87,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
};
|
};
|
||||||
|
if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start';
|
||||||
|
else delete updated.completionIntent;
|
||||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||||
if (nextDueDate === undefined) delete updated.dueDate;
|
if (nextDueDate === undefined) delete updated.dueDate;
|
||||||
delete updated.error;
|
delete updated.error;
|
||||||
|
|
@ -147,7 +150,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
}
|
}
|
||||||
if (!issue) return { blocked: true };
|
if (!issue) return { blocked: true };
|
||||||
discard(item.id);
|
discard(item.id);
|
||||||
return { issue };
|
return { issue, item };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = Number(error?.status || 0);
|
const status = Number(error?.status || 0);
|
||||||
if (status >= 400 && status < 500) {
|
if (status >= 400 && status < 500) {
|
||||||
|
|
@ -166,16 +169,26 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
|
|
||||||
async function flushQueue(currentLogin) {
|
async function flushQueue(currentLogin) {
|
||||||
const confirmed = [];
|
const confirmed = [];
|
||||||
|
const completions = [];
|
||||||
let blocked = 0;
|
let blocked = 0;
|
||||||
currentLogin = String(currentLogin || '').trim();
|
currentLogin = String(currentLogin || '').trim();
|
||||||
for (const item of read()) {
|
for (const item of read()) {
|
||||||
if (item.status === 'attention') continue;
|
if (item.status === 'attention' || item.status === 'completion') continue;
|
||||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||||
const result = await sendItem(item, currentLogin);
|
const result = await sendItem(item, currentLogin);
|
||||||
if (result.issue) confirmed.push(result.issue);
|
if (result.issue) {
|
||||||
|
confirmed.push(result.issue);
|
||||||
|
if (result.item?.completionIntent) completions.push({
|
||||||
|
id: result.item.id,
|
||||||
|
intent: result.item.completionIntent,
|
||||||
|
ownerLogin: result.item.ownerLogin,
|
||||||
|
operationId: result.item.operationId,
|
||||||
|
issue: result.issue,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (result.transient) break;
|
if (result.transient) break;
|
||||||
}
|
}
|
||||||
return { confirmed, remaining: read(), blocked };
|
return { confirmed, completions, remaining: read(), blocked };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flush(currentLogin) {
|
async function flush(currentLogin) {
|
||||||
|
|
@ -202,7 +215,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
write(read().map(candidate => candidate.id === id ? queued : candidate));
|
write(read().map(candidate => candidate.id === id ? queued : candidate));
|
||||||
}
|
}
|
||||||
const result = await sendItem(queued, currentLogin);
|
const result = await sendItem(queued, currentLogin);
|
||||||
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
|
return {
|
||||||
|
confirmed: result.issue ? [result.issue] : [],
|
||||||
|
completions: result.issue && result.item?.completionIntent ? [{
|
||||||
|
id: result.item.id,
|
||||||
|
intent: result.item.completionIntent,
|
||||||
|
ownerLogin: result.item.ownerLogin,
|
||||||
|
operationId: result.item.operationId,
|
||||||
|
issue: result.issue,
|
||||||
|
}] : [],
|
||||||
|
remaining: read(), blocked: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retry(id, currentLogin) {
|
async function retry(id, currentLogin) {
|
||||||
|
|
@ -214,7 +237,12 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||||
const items = read().flatMap(item => {
|
const items = read().flatMap(item => {
|
||||||
const background = statuses.get(item.id);
|
const background = statuses.get(item.id);
|
||||||
if (background?.status === 'sent') return [];
|
if (background?.status === 'sent') {
|
||||||
|
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
|
||||||
|
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
|
||||||
|
}];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
if (background?.status === 'attention') return [{
|
if (background?.status === 'attention') return [{
|
||||||
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||||
|
|
@ -225,7 +253,32 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
function pendingCompletions(currentLogin) {
|
||||||
|
const login = String(currentLogin || '').trim();
|
||||||
|
if (!login) return [];
|
||||||
|
return read().filter(item => item.status === 'completion' && item.ownerLogin === login &&
|
||||||
|
item.completionIntent === 'create-and-start' && item.deliveredIssue).map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
intent: item.completionIntent,
|
||||||
|
ownerLogin: item.ownerLogin,
|
||||||
|
operationId: item.operationId,
|
||||||
|
issue: item.deliveredIssue,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeIntent(id, currentLogin) {
|
||||||
|
const login = String(currentLogin || '').trim();
|
||||||
|
const items = read();
|
||||||
|
const matched = items.some(item => item.id === id && item.status === 'completion' && item.ownerLogin === login);
|
||||||
|
if (!matched) return false;
|
||||||
|
write(items.filter(item => item.id !== id));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground,
|
||||||
|
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v58';
|
const CACHE = 'stackchain-dashboard-shell-v59';
|
||||||
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;
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,38 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
|
||||||
|
script = f"""
|
||||||
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||||
|
const item = {{
|
||||||
|
id:'capture-start',operationId:'capture-start',ownerLogin:'timmy',status:'queued',
|
||||||
|
repository:'stackchain/dashboard',title:'Resume',body:'',labelIds:[],
|
||||||
|
completionIntent:'create-and-start',
|
||||||
|
}};
|
||||||
|
let queued = item;
|
||||||
|
const state = {{completed:[]}};
|
||||||
|
const store = {{
|
||||||
|
claimNext:async () => queued ? (queued=null,{{...item}}) : null,
|
||||||
|
complete:async (id, deliveredIssue) => state.completed.push({{id,deliveredIssue}}),
|
||||||
|
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||||
|
}};
|
||||||
|
const fetchJson = async url => url === 'api/v1/background-identity' ? {{login:'timmy'}} :
|
||||||
|
{{repository:'stackchain/dashboard',number:389,title:'Resume'}};
|
||||||
|
(async()=>{{
|
||||||
|
await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["completed"] == [{
|
||||||
|
"id": "capture-start",
|
||||||
|
"deliveredIssue": {
|
||||||
|
"repository": "stackchain/dashboard", "number": 389, "title": "Resume"
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts():
|
def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,18 @@ def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_
|
||||||
assert "BASE + 'static/create-and-start.js'" in worker
|
assert "BASE + 'static/create-and-start.js'" in worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_start_intent_is_durable_and_has_a_one_tap_relaunch_continuation():
|
||||||
|
dashboard = DASHBOARD.read_text()
|
||||||
|
|
||||||
|
assert "completionIntent: 'create-and-start'" in dashboard
|
||||||
|
assert "result.completions || []" in dashboard
|
||||||
|
assert "(result.completions || []).forEach(completion =>" in dashboard
|
||||||
|
assert "issueOutbox.pendingCompletions(activeFlushLogin)" in dashboard
|
||||||
|
assert "issueOutbox.completeIntent(completion.id, activeFlushLogin)" in dashboard
|
||||||
|
assert 'class="draft-continue"' in dashboard
|
||||||
|
assert "Continue created work" in dashboard
|
||||||
|
|
||||||
|
|
||||||
def test_create_and_start_stops_before_session_when_today_sync_cannot_be_queued():
|
def test_create_and_start_stops_before_session_when_today_sync_cannot_be_queued():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
|
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
|
||||||
|
|
@ -85,3 +97,31 @@ process.stdout.write(JSON.stringify({{result:flow.complete(issue), calls}}));
|
||||||
"result": "sync-unavailable",
|
"result": "sync-unavailable",
|
||||||
"calls": ["Created and saved to Today on this device, but account sync is unavailable."],
|
"calls": ["Created and saved to Today on this device, but account sync is unavailable."],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_start_can_resume_after_today_sync_admission_recovers():
|
||||||
|
script = f"""
|
||||||
|
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
|
||||||
|
const calls=[];let admitted=false;let added=false;
|
||||||
|
const issue={{kind:'issue',repository:'stackchain/dashboard',number:389}};
|
||||||
|
const flow=createCreateAndStart({{
|
||||||
|
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:stackchain/dashboard:389:',add:()=>{{
|
||||||
|
const outcome=added?'exists':'added';added=true;return outcome;
|
||||||
|
}}}},
|
||||||
|
todaySync:{{enqueue:()=>admitted,flush:()=>calls.push('flush')}},
|
||||||
|
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
|
||||||
|
announce:message=>calls.push(message),
|
||||||
|
}});
|
||||||
|
const first=flow.complete(issue);
|
||||||
|
admitted=true;
|
||||||
|
const second=flow.complete(issue);
|
||||||
|
process.stdout.write(JSON.stringify({{first,second,calls}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["first"] == "sync-unavailable"
|
||||||
|
assert output["second"] == "started"
|
||||||
|
assert output["calls"][-5:] == [
|
||||||
|
"refresh", "flush", "warm", "start",
|
||||||
|
"Created, added to Today, and ready to work.",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,26 @@ process.stdout.write(JSON.stringify(drafts));
|
||||||
assert output[1]["label"] == "Queued issue"
|
assert output[1]["label"] == "Queued issue"
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
|
||||||
|
script = f"""
|
||||||
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||||
|
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{
|
||||||
|
id:'continue-1',operationId:'continue-1',repository:'stackchain/dashboard',
|
||||||
|
title:'Resume this issue',body:'',ownerLogin:'timmy',status:'completion',queuedAt:200,
|
||||||
|
completionIntent:'create-and-start',deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume this issue'}}
|
||||||
|
}}]}})]]);
|
||||||
|
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null}};
|
||||||
|
const item = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||||
|
process.stdout.write(JSON.stringify(item));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["status"] == "completion"
|
||||||
|
assert output["label"] == "Created · ready to start"
|
||||||
|
assert output["continuation"] is True
|
||||||
|
assert output["quarantined"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_draft_inbox_exposes_uncertain_delivery_for_explicit_user_verification():
|
def test_draft_inbox_exposes_uncertain_delivery_for_explicit_user_verification():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,57 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
|
||||||
]
|
]
|
||||||
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
|
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
|
||||||
assert all(item["status"] == "queued" for item in output["items"])
|
assert all(item["status"] == "queued" for item in output["items"])
|
||||||
assert output["stored"]["version"] == 2
|
assert output["stored"]["version"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation():
|
||||||
|
script = f"""
|
||||||
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
|
const outbox = createIssueOutbox({{
|
||||||
|
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'create-start-1',
|
||||||
|
fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}}),
|
||||||
|
}});
|
||||||
|
outbox.enqueue({{
|
||||||
|
repository:'stackchain/dashboard',title:'Resume me',body:'',completionIntent:'create-and-start'
|
||||||
|
}});
|
||||||
|
const reloaded = createIssueOutbox({{
|
||||||
|
storage, fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}})
|
||||||
|
}});
|
||||||
|
reloaded.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
||||||
|
persisted:JSON.parse(values.get('stackchain.issue-outbox.v1')),
|
||||||
|
result,
|
||||||
|
}})));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["persisted"]["version"] == 3
|
||||||
|
completion = output["result"]["completions"][0]
|
||||||
|
assert completion["intent"] == "create-and-start"
|
||||||
|
assert completion["ownerLogin"] == "timmy"
|
||||||
|
assert completion["operationId"] == "create-start-1"
|
||||||
|
assert completion["issue"]["number"] == 389
|
||||||
|
|
||||||
|
|
||||||
|
def test_editing_a_queued_issue_updates_its_completion_intent_without_rekeying_delivery():
|
||||||
|
script = f"""
|
||||||
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
const values=new Map();let sequence=0;
|
||||||
|
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
|
const outbox=createIssueOutbox({{
|
||||||
|
storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'intent-' + (++sequence)
|
||||||
|
}});
|
||||||
|
const queued=outbox.enqueue({{repository:'o/r',title:'Work',body:''}});
|
||||||
|
const started=outbox.update(queued.id,{{...queued,completionIntent:'create-and-start'}});
|
||||||
|
const ordinary=outbox.update(queued.id,{{...started,completionIntent:undefined}});
|
||||||
|
process.stdout.write(JSON.stringify({{started,ordinary}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["started"]["completionIntent"] == "create-and-start"
|
||||||
|
assert "completionIntent" not in output["ordinary"]
|
||||||
|
assert output["started"]["operationId"] == output["ordinary"]["operationId"] == "intent-1"
|
||||||
|
|
||||||
|
|
||||||
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
|
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
|
||||||
|
|
@ -384,13 +434,45 @@ process.stdout.write(JSON.stringify(outbox.list()));
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it():
|
||||||
|
script = f"""
|
||||||
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
const values=new Map();
|
||||||
|
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
|
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:3,items:[{{
|
||||||
|
id:'resume',operationId:'resume',ownerLogin:'timmy',status:'queued',
|
||||||
|
repository:'stackchain/dashboard',title:'Resume',body:'',completionIntent:'create-and-start'
|
||||||
|
}}]}}));
|
||||||
|
const outbox=createIssueOutbox({{storage}});
|
||||||
|
outbox.reconcileBackground([{{
|
||||||
|
id:'resume',status:'sent',ownerLogin:'timmy',completionIntent:'create-and-start',
|
||||||
|
deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume'}}
|
||||||
|
}}]);
|
||||||
|
const blocked=outbox.pendingCompletions('alexander');
|
||||||
|
const pending=outbox.pendingCompletions('timmy');
|
||||||
|
const retained=outbox.list();
|
||||||
|
outbox.completeIntent('resume','timmy');
|
||||||
|
process.stdout.write(JSON.stringify({{blocked,pending,retained,remaining:outbox.list()}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["blocked"] == []
|
||||||
|
assert output["pending"] == [{
|
||||||
|
"id": "resume", "intent": "create-and-start", "ownerLogin": "timmy",
|
||||||
|
"operationId": "resume",
|
||||||
|
"issue": {"repository": "stackchain/dashboard", "number": 389, "title": "Resume"},
|
||||||
|
}]
|
||||||
|
assert output["retained"][0]["status"] == "completion"
|
||||||
|
assert output["remaining"] == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
|
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
||||||
assert '<script src="static/issue-outbox.js"></script>' in html
|
assert '<script src="static/issue-outbox.js"></script>' in html
|
||||||
assert "const issueOutbox = createIssueOutbox({" in html
|
assert "const issueOutbox = createIssueOutbox({" in html
|
||||||
assert "await issueOutbox.enqueueDurably(captureDraft)" in html
|
assert "await issueOutbox.enqueueDurably(durableDraft)" in html
|
||||||
assert "Saving for background delivery…" in html
|
assert "Saving for background delivery…" in html
|
||||||
assert "Saved for next launch; background delivery unavailable." in html
|
assert "Saved for next launch; background delivery unavailable." in html
|
||||||
assert "throw new Error('Background Sync unavailable')" in html
|
assert "throw new Error('Background Sync unavailable')" in html
|
||||||
|
|
|
||||||
|
|
@ -347,5 +347,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-v58" in source
|
assert "stackchain-dashboard-shell-v59" in source
|
||||||
assert "BASE + 'static/later-sync.js'" in source
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -137,4 +137,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-v58" in worker
|
assert "stackchain-dashboard-shell-v59" in worker
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,4 @@ 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-v58" in worker
|
assert "stackchain-dashboard-shell-v59" in worker
|
||||||
|
|
|
||||||
|
|
@ -3290,7 +3290,7 @@ async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
|
||||||
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
|
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
|
||||||
assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
|
assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
|
||||||
assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index(
|
assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index(
|
||||||
"issueOutbox.enqueueDurably(captureDraft)"
|
"issueOutbox.enqueueDurably(durableDraft)"
|
||||||
)
|
)
|
||||||
assert ".create-issue-duplicate-card" in html
|
assert ".create-issue-duplicate-card" in html
|
||||||
assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
|
assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
|
||||||
|
|
|
||||||
|
|
@ -101,5 +101,5 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||||
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-v58" in source
|
assert "stackchain-dashboard-shell-v59" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
|
||||||
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-v58" in source
|
assert "stackchain-dashboard-shell-v59" 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
|
||||||
|
|
||||||
|
|
@ -116,14 +116,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
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-v58" in source
|
assert "stackchain-dashboard-shell-v59" 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-v58" in source
|
assert "stackchain-dashboard-shell-v59" 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
|
||||||
|
|
@ -132,21 +132,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-v58" in source
|
assert "stackchain-dashboard-shell-v59" 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-v58" in source
|
assert "stackchain-dashboard-shell-v59" 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-v58" in source
|
assert "stackchain-dashboard-shell-v59" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||||
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-v58" in source
|
assert "stackchain-dashboard-shell-v59" 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