diff --git a/app/src/components/settings/panels/cron/CoreJobList.test.tsx b/app/src/components/settings/panels/cron/CoreJobList.test.tsx
new file mode 100644
index 000000000..b5ec087e5
--- /dev/null
+++ b/app/src/components/settings/panels/cron/CoreJobList.test.tsx
@@ -0,0 +1,78 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, test, vi } from 'vitest';
+
+import type { CoreCronJob, CoreCronRun } from '../../../../utils/tauriCommands';
+import CoreJobList from './CoreJobList';
+
+vi.mock('../../../../lib/i18n/I18nContext', () => ({
+ useT: () => ({
+ t: (key: string) =>
+ ({
+ 'common.enabled': 'Enabled',
+ 'common.remove': 'Remove',
+ 'settings.cron.jobs.desc': 'Manage cron jobs',
+ 'settings.cron.jobs.lastStatus': 'Last status',
+ 'settings.cron.jobs.nextRun': 'Next run',
+ 'settings.cron.jobs.pause': 'Pause',
+ 'settings.cron.jobs.recentRuns': 'Recent runs',
+ 'settings.cron.jobs.schedule': 'Schedule',
+ 'settings.cron.jobs.title': 'Scheduled Jobs',
+ 'settings.cron.jobs.viewRuns': 'View Runs',
+ 'subconscious.runNow': 'Run Now',
+ })[key] ?? key,
+ }),
+}));
+
+const job: CoreCronJob = {
+ id: 'morning_briefing',
+ expression: '0 9 * * *',
+ schedule: { kind: 'cron', expr: '0 9 * * *' },
+ command: 'brief',
+ name: 'Morning Briefing',
+ job_type: 'agent',
+ session_target: 'isolated',
+ enabled: true,
+ delivery: { mode: 'origin', best_effort: true },
+ delete_after_run: false,
+ created_at: '2026-05-18T00:00:00.000Z',
+ next_run: '2026-05-18T09:00:00.000Z',
+ last_status: 'ok',
+};
+
+const run: CoreCronRun = {
+ id: 42,
+ job_id: job.id,
+ started_at: '2026-05-18T09:00:00.000Z',
+ finished_at: '2026-05-18T09:00:05.000Z',
+ status: 'success',
+};
+
+function renderList() {
+ return render(
+
+ );
+}
+
+describe('CoreJobList stable test hooks', () => {
+ test('renders deterministic data-testid hooks for the row and row actions', () => {
+ renderList();
+
+ expect(screen.getByTestId('cron-job-row-morning_briefing')).toBeInTheDocument();
+ expect(screen.getByTestId('cron-job-toggle-morning_briefing')).toHaveTextContent('Pause');
+ expect(screen.getByTestId('cron-job-run-morning_briefing')).toHaveTextContent('Run Now');
+ expect(screen.getByTestId('cron-job-view-runs-morning_briefing')).toHaveTextContent(
+ 'View Runs'
+ );
+ expect(screen.getByTestId('cron-job-remove-morning_briefing')).toHaveTextContent('Remove');
+ expect(screen.getByTestId('cron-job-runs-morning_briefing')).toHaveTextContent('success');
+ });
+});
diff --git a/app/src/components/settings/panels/cron/CoreJobList.tsx b/app/src/components/settings/panels/cron/CoreJobList.tsx
index 14f1a71ab..af6e916dd 100644
--- a/app/src/components/settings/panels/cron/CoreJobList.tsx
+++ b/app/src/components/settings/panels/cron/CoreJobList.tsx
@@ -52,6 +52,7 @@ const CoreJobList = ({
return (
@@ -100,6 +101,7 @@ const CoreJobList = ({
{runs.length > 0 && (
-
+
{t('settings.cron.jobs.recentRuns')}
diff --git a/app/test/e2e/specs/cron-jobs-flow.spec.ts b/app/test/e2e/specs/cron-jobs-flow.spec.ts
index 0215a620d..c47ff3f0f 100644
--- a/app/test/e2e/specs/cron-jobs-flow.spec.ts
+++ b/app/test/e2e/specs/cron-jobs-flow.spec.ts
@@ -57,50 +57,41 @@ async function waitForAnyText(candidates: string[], timeoutMs = 10_000): Promise
return null;
}
-/** Click the action button (Pause | Resume | Remove | …) inside the morning_briefing row. */
-async function clickActionForJob(jobName: string, action: string): Promise
{
+/** Click the action button (Pause | Resume | Remove | …) inside the seeded cron row. */
+async function clickActionForJob(
+ jobId: string,
+ action: 'toggle' | 'run' | 'view-runs' | 'remove'
+): Promise {
return Boolean(
await browser.execute(
- (name: string, label: string) => {
- const rows = Array.from(document.querySelectorAll('div'))
- .filter(div => /text-sm font-semibold text-stone-900/.test(div.className))
- .filter(div => (div.textContent ?? '').trim() === name);
- if (rows.length === 0) return false;
- // Walk up to the panel row container (sibling-of-sibling structure in CoreJobList).
- const container = rows[0]?.closest('div.p-4');
- if (!container) return false;
- const buttons = Array.from(container.querySelectorAll('button'));
- const btn = buttons.find(b => (b.textContent ?? '').trim() === label);
+ (id: string, actionName: string) => {
+ const btn = document.querySelector(
+ `[data-testid="cron-job-${actionName}-${id}"]`
+ );
if (!btn) return false;
btn.click();
return true;
},
- jobName,
+ jobId,
action
)
);
}
-/** Poll for the in-row action button label to settle (e.g. "Pause" → "Resume"). */
+/** Poll for the in-row toggle action button label to settle (e.g. "Pause" → "Resume"). */
async function waitForRowActionLabel(
- jobName: string,
+ jobId: string,
expected: string,
timeoutMs = 10_000
): Promise {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
- const current = await browser.execute((name: string) => {
- const rows = Array.from(document.querySelectorAll('div'))
- .filter(div => /text-sm font-semibold text-stone-900/.test(div.className))
- .filter(div => (div.textContent ?? '').trim() === name);
- const container = rows[0]?.closest('div.p-4');
- if (!container) return null;
- const labels = Array.from(container.querySelectorAll('button')).map(b =>
- (b.textContent ?? '').trim()
+ const current = await browser.execute((id: string) => {
+ const btn = document.querySelector(
+ `[data-testid="cron-job-toggle-${id}"]`
);
- // We care about the toggle button (first one in the row).
- return labels[0] ?? null;
- }, jobName);
+ return (btn?.textContent ?? '').trim() || null;
+ }, jobId);
if (current === expected) return true;
await browser.pause(400);
}
@@ -156,7 +147,7 @@ describe('Cron jobs settings panel (real UI flow)', () => {
const startLabel = await waitForRowActionLabel(MORNING_BRIEFING, 'Pause', 5_000);
expect(startLabel).toBe(true);
- const clicked = await clickActionForJob(MORNING_BRIEFING, 'Pause');
+ const clicked = await clickActionForJob(MORNING_BRIEFING, 'toggle');
expect(clicked).toBe(true);
const flipped = await waitForRowActionLabel(MORNING_BRIEFING, 'Resume', 10_000);
@@ -170,14 +161,14 @@ describe('Cron jobs settings panel (real UI flow)', () => {
expect(stillResumed).toBe(true);
// Restore so the next test starts from the enabled state.
- const restored = await clickActionForJob(MORNING_BRIEFING, 'Resume');
+ const restored = await clickActionForJob(MORNING_BRIEFING, 'toggle');
expect(restored).toBe(true);
const back = await waitForRowActionLabel(MORNING_BRIEFING, 'Pause', 10_000);
expect(back).toBe(true);
});
it('clicking Remove deletes the job from both the UI and the sidecar', async () => {
- const clicked = await clickActionForJob(MORNING_BRIEFING, 'Remove');
+ const clicked = await clickActionForJob(MORNING_BRIEFING, 'remove');
expect(clicked).toBe(true);
// UI assertion first — the row should disappear and the empty state appear.