feat: create follow-up work from updates (Closes #663)
This commit is contained in:
parent
97eb928187
commit
d96a77c5d5
|
|
@ -22,6 +22,7 @@ function normalizeSharedContent(value = {}) {
|
||||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||||
const storageKey = 'stackchain.issue-capture.v1';
|
const storageKey = 'stackchain.issue-capture.v1';
|
||||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||||
|
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let duplicateRequest = 0;
|
let duplicateRequest = 0;
|
||||||
let repositorySearchRequest = 0;
|
let repositorySearchRequest = 0;
|
||||||
|
|
@ -126,6 +127,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
return {status: 'ready'};
|
return {status: 'ready'};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pendingFollowUp() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(storage.getItem(followUpStorageKey) || 'null');
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null;
|
||||||
|
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(parsed.repository || '')) ? String(parsed.repository) : '';
|
||||||
|
const title = String(parsed.title || '').trim().slice(0, 240);
|
||||||
|
const body = String(parsed.body || '').trim().slice(0, 9500);
|
||||||
|
return title || body ? {repository, title, body, labelIds: []} : null;
|
||||||
|
} catch (_error) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptFollowUp() {
|
||||||
|
const followUp = pendingFollowUp();
|
||||||
|
if (!followUp) return loadDraft();
|
||||||
|
const accepted = saveDraft(followUp);
|
||||||
|
try { storage.removeItem(followUpStorageKey); }
|
||||||
|
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardFollowUp() {
|
||||||
|
try { storage.removeItem(followUpStorageKey); }
|
||||||
|
catch (_error) { /* The existing capture remains authoritative. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageFollowUp(value) {
|
||||||
|
const followUp = {repository: String(value?.repository || ''), title: String(value?.title || ''), body: String(value?.body || '')};
|
||||||
|
if (!followUp.title && !followUp.body) return {status: 'empty'};
|
||||||
|
try { storage.setItem(followUpStorageKey, JSON.stringify(followUp)); }
|
||||||
|
catch (_error) { /* The in-page flow can still continue. */ }
|
||||||
|
const existing = loadDraft();
|
||||||
|
if (existing.title || existing.body) return {status: 'conflict'};
|
||||||
|
acceptFollowUp();
|
||||||
|
return {status: 'ready'};
|
||||||
|
}
|
||||||
|
|
||||||
function loadLabels(repository) {
|
function loadLabels(repository) {
|
||||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||||
|
|
@ -254,6 +291,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
searchRepositories, findDuplicates,
|
searchRepositories, findDuplicates,
|
||||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||||
|
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,8 @@
|
||||||
let reviewTrigger = null;
|
let reviewTrigger = null;
|
||||||
let offlineReview = false;
|
let offlineReview = false;
|
||||||
let selectedUpdate = null;
|
let selectedUpdate = null;
|
||||||
|
let selectedUpdateDetail = null;
|
||||||
|
let followUpSourceUpdate = null;
|
||||||
let updateTrigger = null;
|
let updateTrigger = null;
|
||||||
let selectedIssue = null;
|
let selectedIssue = null;
|
||||||
let selectedIssueOffline = false;
|
let selectedIssueOffline = false;
|
||||||
|
|
@ -808,6 +810,7 @@
|
||||||
updateReplyAttachmentController.clear();
|
updateReplyAttachmentController.clear();
|
||||||
}
|
}
|
||||||
selectedUpdate = item;
|
selectedUpdate = item;
|
||||||
|
selectedUpdateDetail = null;
|
||||||
updateMentions.dismiss();
|
updateMentions.dismiss();
|
||||||
qs('#update-sheet').classList.add('open');
|
qs('#update-sheet').classList.add('open');
|
||||||
qs('#update-sheet-key').textContent = item.key || '';
|
qs('#update-sheet-key').textContent = item.key || '';
|
||||||
|
|
@ -826,17 +829,20 @@
|
||||||
qs('#acknowledge-update-next').hidden = true;
|
qs('#acknowledge-update-next').hidden = true;
|
||||||
qs('#update-ownership-action').hidden = true;
|
qs('#update-ownership-action').hidden = true;
|
||||||
qs('#update-ownership-start').hidden = true;
|
qs('#update-ownership-start').hidden = true;
|
||||||
|
qs('#create-update-follow-up').hidden = true;
|
||||||
qs('#retry-update-load').hidden = true;
|
qs('#retry-update-load').hidden = true;
|
||||||
setOfflineUpdateControls(false);
|
setOfflineUpdateControls(false);
|
||||||
qs('#keep-update-unread').focus();
|
qs('#keep-update-unread').focus();
|
||||||
},
|
},
|
||||||
onDetail: async detail => {
|
onDetail: async detail => {
|
||||||
|
selectedUpdateDetail = detail;
|
||||||
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
|
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
|
||||||
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
|
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
|
||||||
qs('#update-subject-state').textContent = detail.state || '';
|
qs('#update-subject-state').textContent = detail.state || '';
|
||||||
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
|
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
|
||||||
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
||||||
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
|
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
|
||||||
|
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
|
||||||
if (offlineWorkMode) {
|
if (offlineWorkMode) {
|
||||||
setOfflineUpdateControls(true);
|
setOfflineUpdateControls(true);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3366,6 +3372,12 @@
|
||||||
clearTimeout(duplicateCheckTimer);
|
clearTimeout(duplicateCheckTimer);
|
||||||
qs('#create-issue-duplicates').hidden = true;
|
qs('#create-issue-duplicates').hidden = true;
|
||||||
creatingIssue = false;
|
creatingIssue = false;
|
||||||
|
if (followUpSourceUpdate) {
|
||||||
|
const source = followUpSourceUpdate;
|
||||||
|
followUpSourceUpdate = null;
|
||||||
|
notificationReader.open(source.item, source.detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
qs('#new-issue').focus();
|
qs('#new-issue').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3610,6 +3622,7 @@
|
||||||
updateReplyAttachmentController.clear();
|
updateReplyAttachmentController.clear();
|
||||||
qs('#update-sheet').classList.remove('open');
|
qs('#update-sheet').classList.remove('open');
|
||||||
selectedUpdate = null;
|
selectedUpdate = null;
|
||||||
|
selectedUpdateDetail = null;
|
||||||
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
||||||
else qs('[data-work-filter="update"]')?.focus();
|
else qs('[data-work-filter="update"]')?.focus();
|
||||||
}
|
}
|
||||||
|
|
@ -4165,6 +4178,22 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||||||
|
const updateFollowUp = createUpdateFollowUp();
|
||||||
|
qs('#create-update-follow-up').addEventListener('click', async () => {
|
||||||
|
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
|
||||||
|
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
|
||||||
|
const state = issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail));
|
||||||
|
followUpSourceUpdate = source;
|
||||||
|
closeUpdateSheet(false, false);
|
||||||
|
await openCreateIssueSheet(false);
|
||||||
|
if (state.status === 'conflict') {
|
||||||
|
qs('#shared-content-conflict').hidden = false;
|
||||||
|
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
|
||||||
|
qs('#resume-issue-draft').focus();
|
||||||
|
} else {
|
||||||
|
qs('#create-issue-status').textContent = 'Follow-up context added. Review, save to Drafts, or create it.';
|
||||||
|
}
|
||||||
|
});
|
||||||
dFS.bind();
|
dFS.bind();
|
||||||
qs('#file-new-issue').addEventListener('click', () => {
|
qs('#file-new-issue').addEventListener('click', () => {
|
||||||
if (!qs('#create-issue-title').value.trim()) {
|
if (!qs('#create-issue-title').value.trim()) {
|
||||||
|
|
@ -4211,7 +4240,8 @@
|
||||||
});
|
});
|
||||||
bindDraftCapacityDialog();
|
bindDraftCapacityDialog();
|
||||||
qs('#use-shared-content').addEventListener('click', () => {
|
qs('#use-shared-content').addEventListener('click', () => {
|
||||||
issueCapture.acceptSharedContent();
|
if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp();
|
||||||
|
else issueCapture.acceptSharedContent();
|
||||||
qs('#shared-content-conflict').hidden = true;
|
qs('#shared-content-conflict').hidden = true;
|
||||||
sharedLaunchState = null;
|
sharedLaunchState = null;
|
||||||
clearSharedLaunchUrl();
|
clearSharedLaunchUrl();
|
||||||
|
|
@ -4220,6 +4250,7 @@
|
||||||
});
|
});
|
||||||
qs('#resume-issue-draft').addEventListener('click', () => {
|
qs('#resume-issue-draft').addEventListener('click', () => {
|
||||||
issueCapture.discardSharedContent();
|
issueCapture.discardSharedContent();
|
||||||
|
issueCapture.discardFollowUp();
|
||||||
qs('#shared-content-conflict').hidden = true;
|
qs('#shared-content-conflict').hidden = true;
|
||||||
sharedLaunchState = null;
|
sharedLaunchState = null;
|
||||||
clearSharedLaunchUrl();
|
clearSharedLaunchUrl();
|
||||||
|
|
|
||||||
|
|
@ -683,6 +683,7 @@
|
||||||
</div>
|
</div>
|
||||||
<button class="share-work-route" type="button">Share</button>
|
<button class="share-work-route" type="button">Share</button>
|
||||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||||
|
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
|
||||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||||
|
|
@ -930,6 +931,7 @@
|
||||||
<script src="static/today-sync.js"></script>
|
<script src="static/today-sync.js"></script>
|
||||||
<script src="static/today-rollover.js"></script>
|
<script src="static/today-rollover.js"></script>
|
||||||
<script src="static/update-ownership.js"></script>
|
<script src="static/update-ownership.js"></script>
|
||||||
|
<script src="static/update-follow-up.js"></script>
|
||||||
<script src="static/later-work.js"></script>
|
<script src="static/later-work.js"></script>
|
||||||
<script src="static/later-sync.js"></script>
|
<script src="static/later-sync.js"></script>
|
||||||
<script src="static/later-and-start.js"></script>
|
<script src="static/later-and-start.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ const SHELL = [
|
||||||
BASE + 'static/today-sync.js',
|
BASE + 'static/today-sync.js',
|
||||||
BASE + 'static/today-rollover.js',
|
BASE + 'static/today-rollover.js',
|
||||||
BASE + 'static/update-ownership.js',
|
BASE + 'static/update-ownership.js',
|
||||||
|
BASE + 'static/update-follow-up.js',
|
||||||
BASE + 'static/later-work.js',
|
BASE + 'static/later-work.js',
|
||||||
BASE + 'static/later-sync.js',
|
BASE + 'static/later-sync.js',
|
||||||
BASE + 'static/later-and-start.js',
|
BASE + 'static/later-and-start.js',
|
||||||
|
|
|
||||||
32
frontend/update-follow-up.js
Normal file
32
frontend/update-follow-up.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
function createUpdateFollowUp() {
|
||||||
|
const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
|
||||||
|
const safeRepository = value => {
|
||||||
|
const candidate = String(value || '');
|
||||||
|
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate) &&
|
||||||
|
candidate.split('/').every(part => part !== '.' && part !== '..') ? candidate : '';
|
||||||
|
};
|
||||||
|
const safeUrl = value => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(String(value || ''));
|
||||||
|
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : '';
|
||||||
|
} catch (_error) { return ''; }
|
||||||
|
};
|
||||||
|
|
||||||
|
function draft(detail = {}) {
|
||||||
|
const source = safeUrl(detail.url || detail.latest_comment?.url);
|
||||||
|
const context = clean(detail.latest_comment?.body || detail.subject_body, 9000);
|
||||||
|
const title = clean(detail.title, 228);
|
||||||
|
const sections = [];
|
||||||
|
if (source) sections.push('Source: ' + source);
|
||||||
|
if (context) sections.push('Latest context:\n> ' + context.replace(/\n/g, '\n> '));
|
||||||
|
return {
|
||||||
|
repository: safeRepository(detail.repository),
|
||||||
|
title: ('Follow up: ' + (title || 'Unread update')).slice(0, 240),
|
||||||
|
body: sections.join('\n\n').slice(0, 9500),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { draft };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
|
||||||
|
|
@ -711,6 +711,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/today-sync.js",
|
"/dashboard/static/today-sync.js",
|
||||||
"/dashboard/static/today-rollover.js",
|
"/dashboard/static/today-rollover.js",
|
||||||
"/dashboard/static/update-ownership.js",
|
"/dashboard/static/update-ownership.js",
|
||||||
|
"/dashboard/static/update-follow-up.js",
|
||||||
"/dashboard/static/later-work.js",
|
"/dashboard/static/later-work.js",
|
||||||
"/dashboard/static/later-sync.js",
|
"/dashboard/static/later-sync.js",
|
||||||
"/dashboard/static/later-and-start.js",
|
"/dashboard/static/later-and-start.js",
|
||||||
|
|
|
||||||
104
tests/test_update_follow_up.py
Normal file
104
tests/test_update_follow_up.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.dashboard_bundle import dashboard
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[1]
|
||||||
|
UPDATE_FOLLOW_UP = ROOT / "frontend" / "update-follow-up.js"
|
||||||
|
CREATE_ISSUE_SHEET = ROOT / "frontend" / "create-issue-sheet.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(script: str) -> dict:
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_follow_up_derives_repository_editable_title_and_canonical_context():
|
||||||
|
script = f"""
|
||||||
|
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
||||||
|
const controller = createFollowUp();
|
||||||
|
process.stdout.write(JSON.stringify(controller.draft({{
|
||||||
|
repository: 'stackchain/stackchain-dashboard',
|
||||||
|
title: 'Fix mobile queue',
|
||||||
|
subject_type: 'Pull',
|
||||||
|
url: 'https://forge.example/git/stackchain/stackchain-dashboard/pulls/42',
|
||||||
|
latest_comment: {{ body: 'Please preserve the unread state. Ship this on mobile.' }}
|
||||||
|
}})));
|
||||||
|
"""
|
||||||
|
assert run_node(script) == {
|
||||||
|
"repository": "stackchain/stackchain-dashboard",
|
||||||
|
"title": "Follow up: Fix mobile queue",
|
||||||
|
"body": (
|
||||||
|
"Source: https://forge.example/git/stackchain/stackchain-dashboard/pulls/42\n\n"
|
||||||
|
"Latest context:\n> Please preserve the unread state. Ship this on mobile."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_follow_up_rejects_unsafe_repository_and_noncanonical_source_url():
|
||||||
|
script = f"""
|
||||||
|
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
||||||
|
const controller = createFollowUp();
|
||||||
|
process.stdout.write(JSON.stringify(controller.draft({{
|
||||||
|
repository: '../admin', title: '<script>alert(1)</script>',
|
||||||
|
url: 'javascript:alert(1)', latest_comment: {{body: 'x'.repeat(12000)}}
|
||||||
|
}})));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
assert output["repository"] == ""
|
||||||
|
assert output["title"] == "Follow up: <script>alert(1)</script>"
|
||||||
|
assert "javascript:" not in output["body"]
|
||||||
|
assert len(output["body"]) <= 9500
|
||||||
|
|
||||||
|
|
||||||
|
def test_follow_up_staging_never_silently_overwrites_an_existing_capture():
|
||||||
|
script = f"""
|
||||||
|
const createCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{
|
||||||
|
getItem:key => values.has(key) ? values.get(key) : null,
|
||||||
|
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
|
||||||
|
}};
|
||||||
|
const capture = createCapture({{fetchJson:async()=>[], storage}});
|
||||||
|
capture.saveDraft({{repository:'o/existing', title:'Existing draft', body:'Keep me'}});
|
||||||
|
const state = capture.stageFollowUp({{repository:'o/new', title:'Follow up', body:'Source: https://example.test/1'}});
|
||||||
|
const before = capture.loadDraft();
|
||||||
|
const accepted = capture.acceptFollowUp();
|
||||||
|
process.stdout.write(JSON.stringify({{state, before, accepted, pending:capture.pendingFollowUp()}}));
|
||||||
|
"""
|
||||||
|
assert run_node(script) == {
|
||||||
|
"state": {"status": "conflict"},
|
||||||
|
"before": {
|
||||||
|
"repository": "o/existing",
|
||||||
|
"title": "Existing draft",
|
||||||
|
"body": "Keep me",
|
||||||
|
"labelIds": [],
|
||||||
|
},
|
||||||
|
"accepted": {
|
||||||
|
"repository": "o/new",
|
||||||
|
"title": "Follow up",
|
||||||
|
"body": "Source: https://example.test/1",
|
||||||
|
"labelIds": [],
|
||||||
|
},
|
||||||
|
"pending": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_sheet_wires_phone_safe_follow_up_without_marking_read():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert '<script src="static/update-follow-up.js"></script>' in html
|
||||||
|
assert 'id="create-update-follow-up"' in html
|
||||||
|
assert '>Create follow-up</button>' in html
|
||||||
|
assert "issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail))" in html
|
||||||
|
assert "qs('#create-update-follow-up').addEventListener('click'" in html
|
||||||
|
assert "markNotificationRead" not in html.split("qs('#create-update-follow-up').addEventListener('click'", 1)[1].split("});", 1)[0]
|
||||||
|
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
||||||
Loading…
Reference in New Issue
Block a user