mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-23 08:32:20 +02:00
409 lines
20 KiB
Python
409 lines
20 KiB
Python
from pathlib import Path
|
|
import subprocess
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def test_shared_action_menu_order_is_used_by_item_menus() -> None:
|
|
expected_imports = {
|
|
"static/js/documentLibrary.js": "orderActionMenuItems",
|
|
"static/js/tasks.js": "orderActionMenuItems",
|
|
"static/js/sessions.js": "orderActionMenuItems",
|
|
"static/js/research/panel.js": "orderActionMenuItems",
|
|
"static/js/emailLibrary.js": "orderActionMenuItems",
|
|
"static/js/memory.js": "orderActionMenuItems",
|
|
}
|
|
for relative_path, helper in expected_imports.items():
|
|
source = (ROOT / relative_path).read_text(encoding="utf-8")
|
|
assert "actionMenuOrder.js" in source
|
|
assert helper in source
|
|
|
|
|
|
def test_common_action_order_matches_product_convention() -> None:
|
|
source = (ROOT / "static/js/actionMenuOrder.js").read_text(encoding="utf-8")
|
|
for rank in (200, 400, 500, 550, 600, 650, 700, 900, 1000):
|
|
assert f"{{ rank: {rank}" in source
|
|
|
|
|
|
def test_callback_actions_are_sorted_by_their_labels() -> None:
|
|
script = """
|
|
import { orderActionMenuItems } from './static/js/actionMenuOrder.js';
|
|
const callback = () => {};
|
|
const items = [
|
|
{ label: 'Delete', action: callback },
|
|
{ label: 'Archive', action: callback },
|
|
{ label: 'Copy', action: callback },
|
|
{ label: 'Favorite', action: callback },
|
|
{ label: 'Select', action: callback },
|
|
{ label: 'Rename', action: callback },
|
|
{ label: 'Open', action: callback },
|
|
{ label: 'Cancel', action: callback },
|
|
];
|
|
process.stdout.write(orderActionMenuItems(items).map(item => item.label).join('|'));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "--input-type=module", "--eval", script],
|
|
cwd=ROOT,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.stdout == "Open|Rename|Favorite|Copy|Select|Archive|Delete|Cancel"
|
|
|
|
|
|
def test_action_order_module_is_precached() -> None:
|
|
service_worker = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
|
assert "'/static/js/actionMenuOrder.js'" in service_worker
|
|
|
|
|
|
def test_dropdown_select_actions_use_the_canonical_icon() -> None:
|
|
source = (ROOT / "static/js/actionMenuOrder.js").read_text(encoding="utf-8")
|
|
assert "export const SELECT_MENU_ICON" in source
|
|
for relative_path in (
|
|
"static/js/documentLibrary.js",
|
|
"static/js/memory.js",
|
|
"static/js/sessions.js",
|
|
"static/js/skills.js",
|
|
"static/js/tasks.js",
|
|
"static/js/emailLibrary.js",
|
|
"static/js/research/panel.js",
|
|
):
|
|
module = (ROOT / relative_path).read_text(encoding="utf-8")
|
|
assert "SELECT_MENU_ICON" in module
|
|
|
|
|
|
def test_email_filter_menu_has_context_title() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
assert 'email-filter-menu-title">Filter by...</div>' in source
|
|
|
|
|
|
def test_email_setting_toggles_render_neutral_disabled_state() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
|
assert 'email-settings-auto-reply-section' in source
|
|
assert 'email-settings-display-enabled-state' in source
|
|
assert 'stateLabel = section?.querySelector' in source
|
|
assert '.email-settings-section.is-disabled .email-settings-enabled-state' in style
|
|
|
|
|
|
def test_email_search_options_menu_has_context_title() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
menu_start = source.index('id="email-search-options-menu"')
|
|
menu_end = source.index("</div>", menu_start) + len("</div>")
|
|
assert 'email-search-options-title">Filter by...</div>' in source[menu_start:menu_end]
|
|
|
|
|
|
def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
assert "function _emailTimelineGapThreshold(items)" in source
|
|
assert "email-date-gap-break" in source
|
|
assert "gapDays > 90 && gapDays > timelineGapThreshold" in source
|
|
|
|
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
|
assert ".date-section-header.email-date-gap-break" in style
|
|
|
|
|
|
def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
assert '<option value="tag:action-needed">' not in source
|
|
assert "filter:tag:action-needed" not in source
|
|
assert "email-card-favorite" in source
|
|
assert "aria-pressed" in source
|
|
assert "/api/email/flag/" in source
|
|
assert "Object.prototype.hasOwnProperty.call(em, 'is_flagged')" in source
|
|
assert "const favoritesView = state._libFilter === 'favorites';" in source
|
|
assert "statusCluster.insertBefore(favoriteToggle, doneControl)" in source
|
|
assert "statusCluster.className = 'email-card-status';" in source
|
|
assert "statusCluster.appendChild(att)" in source
|
|
assert "statusCluster.appendChild(doneCheck)" in source
|
|
assert "function _exactTypedFilterSuggestion(value)" in source
|
|
assert "opt.value === 'filter:has-attachments'" in source
|
|
assert "const typedFilter = _exactTypedFilterSuggestion(v);" in source
|
|
assert "_acceptSuggestion(typedFilter);" in source
|
|
|
|
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
|
favorite_start = style.index(".email-card-favorite {")
|
|
favorite_end = style.index("}", favorite_start) + 1
|
|
assert "top: -3px;" in style[favorite_start:favorite_end]
|
|
assert ".email-card-status" in style
|
|
|
|
|
|
def test_email_auto_reply_start_date_seeds_today_when_picker_opens() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
assert "function _todayDateInputValue()" in source
|
|
assert "if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue();" in source
|
|
assert "autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate);" in source
|
|
assert "autoReplyStart?.addEventListener('focus', seedAutoReplyStartDate);" in source
|
|
|
|
|
|
def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
assert "function _syncAutoReplyCalendarEvent(cfg)" in source
|
|
assert "summary: 'Email Auto Reply (away)'" in source
|
|
assert "function _findAutoReplyCalendarEventUids(cfg, accountId)" in source
|
|
assert "Odysseus email auto reply - account:" in source
|
|
assert "_AUTO_REPLY_CALENDAR_KEY_PREFIX" in source
|
|
assert "method: 'POST', body: JSON.stringify(payload)" in source
|
|
assert "method: 'PUT', body: JSON.stringify(payload)" in source
|
|
assert "method: 'DELETE'" in source
|
|
assert "all_day: true" in source
|
|
assert "await _syncAutoReplyCalendarEvent(savedCfg)" in source
|
|
assert "_syncAutoReplyCalendarEvent(cfg).catch" in source
|
|
|
|
|
|
def test_email_settings_show_away_account_and_compact_display_controls() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
|
assert 'email-account-away-label">(AWAY)</span>' in source
|
|
assert 'id="email-lib-auto-reply-badge"' in source
|
|
assert ">Show Email Tags</span>" in source
|
|
assert "enabled ? 'Show' : 'Hide'" in source
|
|
assert "email-settings-inline-link" in source
|
|
assert "email-auto-reply-exclude" not in source
|
|
assert "_emailWritingStyleHtml(writingStyle) + _emailDisplaySettingsHtml()" in source
|
|
assert ".email-settings-status.is-success" in style
|
|
assert "var(--color-success, #4caf50)" in style
|
|
assert ".email-style-settings-extract svg" in style
|
|
assert "export async function mountEmailSettings(host)" in source
|
|
assert "_openGlobalEmailSettings('show-tags')" in source
|
|
assert source.count('class="admin-card email-settings-section') == 4
|
|
assert 'id="settings-email-default-card"' in (ROOT / "static/index.html").read_text(encoding="utf-8")
|
|
assert "multipleAccounts" in source
|
|
|
|
|
|
def test_email_cleanup_uses_the_memory_tidy_star_icon() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
cleanup = source[source.index("function _emailCleanupSettingsHtml"):source.index("function _emailDisplaySettingsHtml")]
|
|
assert "email-settings-clean-btn" in cleanup
|
|
assert "M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z" in cleanup
|
|
|
|
|
|
def test_email_settings_escape_returns_to_email_list() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
settings_guard = "if (modal.classList.contains('email-settings-mode'))"
|
|
assert settings_guard in source
|
|
assert source.index(settings_guard) < source.index("closeEmailLibrary();", source.index(settings_guard))
|
|
assert "_hideEmailSettingsPage();" in source[source.index(settings_guard):source.index(settings_guard) + 180]
|
|
|
|
|
|
def test_email_select_escape_cancels_selection_without_closing_library() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
select_guard = "if (state._selectMode) {"
|
|
select_start = source.index(select_guard, source.index("if (e.key === 'Escape')"))
|
|
assert "_setSelectBtnState(false);" in source[select_start:select_start + 260]
|
|
assert "closeEmailLibrary();" not in source[select_start:select_start + 260]
|
|
|
|
|
|
def test_chat_delete_actions_use_the_shared_trash_bin_icon() -> None:
|
|
source = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
|
assert "const TRASH_ICON =" in source
|
|
assert "{ id: 'delete', icon: TRASH_ICON" in source
|
|
assert source.count("{ id: 'delete', icon: TRASH_ICON") == 2
|
|
assert "M3 6h18" in source
|
|
|
|
|
|
def test_agent_unsubscribe_uses_the_reviewed_target_without_rescanning() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
start = source.index("function _askAgentToUnsubscribe")
|
|
end = source.index("function _unsubscribeCandidateUids", start)
|
|
prompt = source[start:end]
|
|
assert "private_browser" in prompt
|
|
assert "Do not call scan_email_unsubscribes again" in prompt
|
|
assert "Reviewed method_index" in prompt
|
|
assert "Exact unsubscribe URL" in prompt
|
|
assert "bulk_email action=delete" in prompt
|
|
assert "Email UID(s)" in prompt
|
|
|
|
|
|
def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
start = source.index("function _bindEmailSettingsPageControls")
|
|
end = source.index("function _setUnsubButtonBusy", start)
|
|
controls = source[start:end]
|
|
assert "_openUnsubscribeReviewModal(ev.currentTarget, { forceRescan: true })" in controls
|
|
assert "statusEl.style.justifyContent = 'flex-end'" in source
|
|
|
|
|
|
def test_unsubscribe_duplicate_badge_is_lowered() -> None:
|
|
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
stylesheet = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
|
assert "email-unsub-duplicate-badge" in frontend
|
|
start = stylesheet.index(".email-unsub-duplicate-badge {")
|
|
assert "top: 2px;" in stylesheet[start:stylesheet.index("}", start) + 1]
|
|
|
|
|
|
def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
|
|
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
stylesheet = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
|
start = frontend.index("function _emailCleanupSettingsHtml")
|
|
end = frontend.index("function _emailDisplaySettingsHtml", start)
|
|
cleanup = frontend[start:end]
|
|
assert "email-settings-clean-actions" in cleanup
|
|
assert cleanup.index("email-settings-clean-status") < cleanup.index("email-settings-clean-btn")
|
|
assert "inlineHost.querySelector('.email-settings-clean-status')" in frontend
|
|
css_start = stylesheet.index(".email-settings-clean-status {")
|
|
assert "width: auto !important;" in stylesheet[css_start:stylesheet.index("}", css_start) + 1]
|
|
assert "email-unsub-panel-status" in frontend
|
|
assert "modal.style.cssText = 'display:none;margin-top:10px;'" in frontend
|
|
assert "showFinalStatus(finalStatus)" in frontend
|
|
assert "email-unsub-delete-all-btn').style.display = candidates.length ? 'inline-flex' : 'none'" in frontend
|
|
assert "statusEl.classList.add('is-busy')" in frontend
|
|
assert ".email-settings-clean-actions:has(.email-settings-clean-status.is-busy)" in stylesheet
|
|
panel_css = stylesheet[stylesheet.index(".email-unsub-panel-status {"):]
|
|
assert "top: 2px;" in panel_css[:panel_css.index("}") + 1]
|
|
|
|
|
|
def test_unsubscribe_success_removes_messages_before_the_next_scan() -> None:
|
|
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
backend = (ROOT / "routes/email_routes.py").read_text(encoding="utf-8")
|
|
mcp = (ROOT / "mcp_servers/email_server.py").read_text(encoding="utf-8")
|
|
assert "async function _deleteAfterUnsubscribe" in frontend
|
|
assert "action: 'delete'" in frontend[frontend.index("async function _deleteAfterUnsubscribe"):]
|
|
execute = backend[backend.index('@router.post("/unsubscribe/execute")'):]
|
|
assert 'deleted = _move_email_message(conn, uid, "Trash", role="trash")' in execute
|
|
assert '"deleted": deleted' in execute
|
|
unsubscribe = mcp[mcp.index("def _unsubscribe_email"):mcp.index("def _extract_text", mcp.index("def _unsubscribe_email"))]
|
|
assert "_delete_email(uid, folder=folder, account=account)" in unsubscribe
|
|
assert '"deleted": deleted' in unsubscribe
|
|
|
|
|
|
def test_agent_email_mutations_reconcile_bulk_single_and_mailto_results() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
start = source.index("function _agentDeletedEmailUids")
|
|
end = source.index("function _handleAgentEmailToolOutput", start)
|
|
resolver = source[start:end]
|
|
assert "tool.includes('bulk_email')" in resolver
|
|
assert "tool.endsWith('delete_email')" in resolver
|
|
assert "source email moved to trash" in resolver
|
|
assert "data.uid" in resolver
|
|
|
|
|
|
def test_browser_agent_unsubscribe_cleans_sender_after_positive_confirmation() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
start = source.index("function _agentBrowserUnsubscribeSucceeded")
|
|
end = source.index("function _agentDeletedEmailUids", start)
|
|
browser_flow = source[start:end]
|
|
assert "already\\s+unsubscribed" in browser_flow
|
|
assert "_deleteAfterUnsubscribe([candidate])" in browser_flow
|
|
assert "scope: group.sender ? 'sender_unsubscribe'" in source
|
|
assert "cleanupInFlight" in browser_flow
|
|
|
|
|
|
def test_auto_unsubscribe_all_is_visibly_taller_than_toolbar_buttons() -> None:
|
|
source = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
|
start = source.index(".email-unsub-auto-safe-btn {")
|
|
assert "height: 29px;" in source[start:source.index("}", start) + 1]
|
|
|
|
|
|
def test_email_mutation_tool_events_include_exact_arguments() -> None:
|
|
source = (ROOT / "src/agent_loop.py").read_text(encoding="utf-8")
|
|
start = source.index("# Emit tool_output (include ui_event data if present)")
|
|
end = source.index("if tool_call_id:", start)
|
|
event = source[start:end]
|
|
assert '"mcp__email__bulk_email"' in event
|
|
assert '"mcp__email__delete_email"' in event
|
|
assert '"mcp__email__unsubscribe_email"' in event
|
|
assert '"private_browser"' in event
|
|
assert 'tool_output_data["tool_args"]' in event
|
|
|
|
|
|
def test_unsubscribe_cleanup_can_remove_same_sender_unsubscribe_messages() -> None:
|
|
source = (ROOT / "routes" / "email_routes.py").read_text()
|
|
cleanup = source[source.index('@router.post("/unsubscribe/cleanup")'):source.index('@router.get("/contacts")')]
|
|
assert 'scope == "sender_unsubscribe"' in cleanup
|
|
assert "_unsubscribe_sender_uids_sync" in cleanup
|
|
sender_scan = source[source.index("def _unsubscribe_sender_uids_sync"):source.index('@router.get("/unsubscribe/scan")')]
|
|
assert "FROM {_imap_search_quote(sender_key)}" in sender_scan
|
|
assert 'candidate.get("from_address")' in source[source.index("def _unsubscribe_sender_uids_sync"):source.index('@router.get("/unsubscribe/scan")')]
|
|
|
|
|
|
def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> None:
|
|
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
|
start = source.index("function _markUnsubscribeCardDone")
|
|
end = source.index("async function _runUnsubscribeCleanup", start)
|
|
card = source[start:end]
|
|
assert "_UNSUB_CHECK_ICON" in card
|
|
assert "is-unsubscribed" in card
|
|
assert "email-unsub-scan-further" in source
|
|
|
|
|
|
def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None:
|
|
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
|
styles = (ROOT / "static" / "style.css").read_text()
|
|
assert "email-unsub-ignore-btn" in source
|
|
assert "_rememberUnsubscribeIgnored(c)" in source
|
|
assert "Ignore this unsubscribe candidate" in source
|
|
assert ".email-unsub-ignore-btn" in styles
|
|
|
|
|
|
def test_email_settings_sections_use_static_headers() -> None:
|
|
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
|
styles = (ROOT / "static" / "style.css").read_text()
|
|
assert 'class="email-unsub-accent-icon"' in source
|
|
assert 'M12 0L14.59 8.41' in source
|
|
assert 'Scanning ${_esc(scanFolderLabel)} headers…' in source
|
|
assert 'email-settings-clean-btn' in source
|
|
assert 'const inlineHost = settingsPage?.querySelector?.(\'.email-settings-cleanup-section\')' in source
|
|
assert '<div class="admin-card email-settings-section' in source
|
|
assert 'class="email-settings-section-head"' in source
|
|
assert '<div id="email-settings-cleanup-body" class="email-settings-section-body">' in source
|
|
assert '<details class="admin-card email-settings-section' not in source
|
|
assert source.count('id="email-auto-reply-enabled"') == 1
|
|
assert source.count('id="email-settings-show-tags"') == 1
|
|
assert 'flex: 0 0 auto;' in styles
|
|
assert 'page.querySelectorAll(\'details.email-settings-section\')' not in source
|
|
assert 'other.open = false' not in source
|
|
assert '.email-settings-section[open] > .email-settings-section-body' in styles
|
|
assert '.email-settings-section[open] > .email-settings-section-body > *' in styles
|
|
assert 'flex: 0 0 auto;' in styles
|
|
assert '.email-settings-section[open] {' in styles
|
|
assert 'flex: 1 1 auto;' in styles
|
|
assert 'grid-template-rows: auto minmax(0, 1fr);' in styles
|
|
assert 'height: 100%;' in styles
|
|
assert 'max-height: 100%;' in styles
|
|
assert 'overflow: hidden;' in styles
|
|
assert '.modal-content:not([style*="height"])' in styles
|
|
assert 'left: -1px;' in styles
|
|
assert '.email-settings-clean-status:not(:empty)' in styles
|
|
assert '.email-unsub-status.is-error' in styles
|
|
|
|
|
|
def test_unsubscribe_scan_defaults_to_bounded_page_in_api_and_tool_prompt() -> None:
|
|
backend = (ROOT / "routes" / "email_routes.py").read_text()
|
|
schema = (ROOT / "src" / "tool_schemas.py").read_text()
|
|
agent = (ROOT / "src" / "agent_loop.py").read_text()
|
|
scan_start = backend.index('@router.get("/unsubscribe/scan")')
|
|
scan_end = backend.index('@router.post("/unsubscribe/execute")', scan_start)
|
|
scan_route = backend[scan_start:scan_end]
|
|
assert 'max_scan: int = Query(500)' in scan_route
|
|
assert 'max_scan = max(limit, min(requested_max_scan or 500, 500))' in backend
|
|
assert 'for start in range(0, len(uids), 100)' in backend
|
|
assert 'capped at 500' in schema
|
|
assert '"max_scan": 500' in agent[agent.index('def _parse_qwen_explicit_unsubscribe_scan_request'):agent.index('def _parse_qwen_explicit_unsubscribe_email_request')]
|
|
mcp = (ROOT / "mcp_servers" / "email_server.py").read_text()
|
|
mcp_scan = mcp[mcp.index('def _scan_unsubscribe_candidates'):mcp.index('def _unsubscribe_email')]
|
|
assert 'max_scan=500' in mcp_scan
|
|
assert 'for start in range(0, len(uids), 100)' in mcp_scan
|
|
assert 'capped at 500' in mcp
|
|
assert 'Scan up to 500 newest email headers' in mcp
|
|
|
|
|
|
def test_item_menus_share_the_standard_dropdown_classes() -> None:
|
|
expected = {
|
|
"static/js/documentLibrary.js": "dropdown session-dropdown-menu doclib-card-dropdown",
|
|
"static/js/memory.js": "dropdown session-dropdown-menu memory-item-dropdown",
|
|
"static/js/tasks.js": "dropdown session-dropdown-menu task-dropdown",
|
|
"static/js/skills.js": "dropdown session-dropdown-menu skill-kebab-menu",
|
|
}
|
|
for relative_path, class_names in expected.items():
|
|
source = (ROOT / relative_path).read_text(encoding="utf-8")
|
|
assert class_names in source
|
|
|
|
|
|
def test_task_card_menu_can_enter_select_mode_with_current_task() -> None:
|
|
source = (ROOT / "static/js/tasks.js").read_text(encoding="utf-8")
|
|
assert "function _taskEnterSelectWith(taskId)" in source
|
|
assert "label: 'Select'" in source
|
|
assert "action: () => _taskEnterSelectWith(task.id)" in source
|