test(cron): add stable job row selectors (#2062)

Co-authored-by: honor2030 <9779460+honor2030@users.noreply.github.com>
This commit is contained in:
이민재
2026-05-19 14:34:54 -07:00
committed by GitHub
co-authored by honor2030
parent 73ea22e011
commit b2ecdba4a8
3 changed files with 106 additions and 30 deletions
@@ -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(
<CoreJobList
loading={false}
coreJobs={[job]}
coreRunsByJob={{ [job.id]: [run] }}
coreBusyKey={null}
onToggleCoreJob={vi.fn()}
onRunCoreJob={vi.fn()}
onLoadCoreRuns={vi.fn()}
onRemoveCoreJob={vi.fn()}
/>
);
}
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');
});
});
@@ -52,6 +52,7 @@ const CoreJobList = ({
return (
<div
key={job.id}
data-testid={`cron-job-row-${job.id}`}
className={`p-4 ${index === 0 ? '' : 'border-t border-stone-200 dark:border-neutral-800'} space-y-3`}>
<div className="flex items-start justify-between gap-3">
<div>
@@ -100,6 +101,7 @@ const CoreJobList = ({
<div className="flex flex-wrap gap-2">
<button
type="button"
data-testid={`cron-job-toggle-${job.id}`}
className="btn btn-sm btn-outline"
disabled={coreBusyKey === `core-toggle:${job.id}`}
onClick={() => onToggleCoreJob(job)}>
@@ -111,6 +113,7 @@ const CoreJobList = ({
</button>
<button
type="button"
data-testid={`cron-job-run-${job.id}`}
className="btn btn-sm btn-outline"
disabled={coreBusyKey === `core-run:${job.id}`}
onClick={() => onRunCoreJob(job.id)}>
@@ -120,6 +123,7 @@ const CoreJobList = ({
</button>
<button
type="button"
data-testid={`cron-job-view-runs-${job.id}`}
className="btn btn-sm btn-outline"
disabled={coreBusyKey === `core-runs:${job.id}`}
onClick={() => onLoadCoreRuns(job.id)}>
@@ -129,6 +133,7 @@ const CoreJobList = ({
</button>
<button
type="button"
data-testid={`cron-job-remove-${job.id}`}
className="btn btn-sm btn-error"
disabled={coreBusyKey === `core-remove:${job.id}`}
onClick={() => onRemoveCoreJob(job.id)}>
@@ -139,7 +144,9 @@ const CoreJobList = ({
</div>
{runs.length > 0 && (
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 space-y-1">
<div
data-testid={`cron-job-runs-${job.id}`}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 space-y-1">
<div className="text-[11px] uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('settings.cron.jobs.recentRuns')}
</div>
+20 -29
View File
@@ -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<boolean> {
/** Click the action button (Pause | Resume | Remove | …) inside the seeded cron row. */
async function clickActionForJob(
jobId: string,
action: 'toggle' | 'run' | 'view-runs' | 'remove'
): Promise<boolean> {
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<HTMLButtonElement>('button'));
const btn = buttons.find(b => (b.textContent ?? '').trim() === label);
(id: string, actionName: string) => {
const btn = document.querySelector<HTMLButtonElement>(
`[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<boolean> {
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<HTMLButtonElement>('button')).map(b =>
(b.textContent ?? '').trim()
const current = await browser.execute((id: string) => {
const btn = document.querySelector<HTMLButtonElement>(
`[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.