mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
test(e2e): use stable cron test ids
## Summary - Adds shared `waitForTestId` and `clickTestId` E2E helpers for stable DOM hooks. - Adds missing `cron-jobs-panel` and `cron-refresh` hooks to the Cron Jobs settings panel. - Migrates the reference cron E2E flow away from Tailwind class/DOM walking to cron row/action test IDs. - Documents the E2E `data-testid` naming taxonomy in the E2E guide. ## Problem - #1861 calls out `cron-jobs-flow.spec.ts` as the concrete example of brittle selectors: it found `morning_briefing` via Tailwind class strings and `closest('div.p-4')`. - That breaks on harmless class/layout refactors and makes the reference spec a weak template for future E2E work. ## Solution - Use existing `CoreJobList` cron row/action hooks directly from the spec. - Add the missing panel/refresh hooks so the whole cron flow can avoid text/button discovery for row actions. - Centralize test ID waiting/clicking in `element-helpers.ts` instead of keeping local DOM snippets in specs. ## Submission Checklist - [x] Tests added or updated (happy path + at least one failure / edge case) per [Testing Strategy](../gitbooks/developing/testing-strategy.md#failure-path-requirement) - updated the reference cron E2E spec and helper path. - [x] **Diff coverage >= 80%** - N/A locally: E2E helper/spec/doc change; CI diff coverage is authoritative. - [x] Coverage matrix updated - N/A: E2E selector-hardening change, no feature row added/removed/renamed. - [x] All affected feature IDs from the matrix are listed in the PR description under `## Related` - N/A: no matrix feature ID applies. - [x] No new external network dependencies introduced (mock backend used per [Testing Strategy](../gitbooks/developing/testing-strategy.md#mock-policy)) - [x] Manual smoke checklist updated if this touches release-cut surfaces ([`docs/RELEASE-MANUAL-SMOKE.md`](../docs/RELEASE-MANUAL-SMOKE.md)) - N/A: no release-cut surface. - [x] Linked issue closed via `Closes #NNN` in the `## Related` section - N/A: this is a scoped slice that references #1861 without closing the broader audit issue. ## Impact - Runtime impact: none outside stable `data-testid` attributes on the Cron Jobs panel. - Test impact: cron E2E is less coupled to Tailwind classes and container structure. - Compatibility: `waitForTestId` is documented as tauri-driver/DOM-backed; Mac2 specs should keep accessibility/text helpers unless IDs are mirrored into accessible labels. ## Related - Refs #1861 - Follow-up PR(s)/TODOs: add stable hooks/migrations for the remaining settings, skills, conversation, onboarding, and notification E2E surfaces tracked by #1861. --- ## AI Authored PR Metadata (required for Codex/Linear PRs) ### Linear Issue - Key: N/A - URL: N/A ### Commit & Branch - Branch: `codex/1861-cron-e2e-testids` - Commit SHA: `28eca40a3d2e3a75b61105887e4faf58d4c0866e` ### Validation Run - [x] `pnpm --filter openhuman-app exec prettier --check src/components/settings/panels/CronJobsPanel.tsx test/e2e/helpers/element-helpers.ts test/e2e/specs/cron-jobs-flow.spec.ts` - passed - [x] `pnpm typecheck` - passed - [x] Focused tests: N/A, E2E spec migration not run locally - [x] Rust fmt/check (if changed): `cargo fmt --all --check` - passed; `git diff --check` - passed - [x] Tauri fmt/check (if changed): N/A ### Validation Blocked - `command:` `pnpm --filter openhuman-app exec tsc -p test/tsconfig.e2e.json --noEmit` - `error:` local command could not find `tsc` / `@wdio/globals/types` via that invocation - `impact:` app-wide `pnpm typecheck` passes; remote E2E/TS jobs remain authoritative for the E2E tsconfig ### Behavior Changes - Intended behavior change: none for users; E2E selectors now target stable hooks. - User-visible effect: none. ### Parity Contract - Legacy behavior preserved: cron load, refresh, pause/resume, run-history, and remove behavior unchanged. - Guard/fallback/dispatch parity checks: spec still asserts the same UI and sidecar persistence behavior. ### Duplicate / Superseded PR Handling - Duplicate PR(s): N/A - Canonical PR: this PR - Resolution (closed/superseded/updated): N/A <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Added E2E helpers and updated cron-jobs flow to use stable test identifiers for more reliable UI interactions and polling. * **Documentation** * Added guidance on stable test ID naming and how to use the new E2E helpers in the testing guide. * **Maintenance** * Cron Jobs panel and controls now expose stable identifiers for testing (no user-facing behavior changes). <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/tinyhumansai/openhuman/pull/2301?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: aqilaziz <gonzes7@gmail.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
ec1352f059
commit
0f5e91421b
@@ -131,7 +131,7 @@ const CronJobsPanel = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="cron-jobs-panel">
|
||||
<SettingsHeader
|
||||
title={t('cron.title')}
|
||||
showBackButton={true}
|
||||
@@ -166,6 +166,7 @@ const CronJobsPanel = () => {
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="cron-refresh"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => void loadCoreCronJobsOnly()}>
|
||||
{t('cron.refreshCronJobs')}
|
||||
|
||||
@@ -356,6 +356,47 @@ export async function clickSelector(
|
||||
return el;
|
||||
}
|
||||
|
||||
function testIdSelector(testId: string): string {
|
||||
return `[data-testid="${testId}"]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element by stable `data-testid`.
|
||||
*
|
||||
* This is currently supported on tauri-driver, where WDIO can query the DOM.
|
||||
* Mac2 exposes the accessibility tree instead, so specs that must run there
|
||||
* should keep using text/accessibility helpers unless the app mirrors the
|
||||
* test id into an accessible label.
|
||||
*/
|
||||
export async function waitForTestId(
|
||||
testId: string,
|
||||
timeout: number = 15_000
|
||||
): Promise<ChainablePromiseElement> {
|
||||
if (!isTauriDriver()) {
|
||||
throw new Error(`waitForTestId is only supported on tauri-driver: ${testId}`);
|
||||
}
|
||||
|
||||
const selector = testIdSelector(testId);
|
||||
const el = await browser.$(selector);
|
||||
await el.waitForExist({
|
||||
timeout,
|
||||
timeoutMsg: `data-testid="${testId}" not found within ${timeout}ms`,
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element by stable `data-testid`, then click it.
|
||||
*/
|
||||
export async function clickTestId(
|
||||
testId: string,
|
||||
timeout: number = 15_000
|
||||
): Promise<ChainablePromiseElement> {
|
||||
const el = await waitForTestId(testId, timeout);
|
||||
await clickAtElement(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a label whose visible text contains `text`.
|
||||
*
|
||||
|
||||
@@ -28,7 +28,13 @@
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { clickButton, textExists, waitForText } from '../helpers/element-helpers';
|
||||
import {
|
||||
clickNativeButton,
|
||||
clickTestId,
|
||||
textExists,
|
||||
waitForTestId,
|
||||
waitForText,
|
||||
} from '../helpers/element-helpers';
|
||||
import { resetApp } from '../helpers/reset-app';
|
||||
import { navigateToSettings, navigateViaHash } from '../helpers/shared-flows';
|
||||
import { startMockServer, stopMockServer } from '../mock-server';
|
||||
@@ -57,51 +63,93 @@ 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> {
|
||||
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);
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
},
|
||||
jobName,
|
||||
action
|
||||
)
|
||||
);
|
||||
function cronActionTestId(jobId: string, action: string): string | null {
|
||||
switch (action) {
|
||||
case 'Pause':
|
||||
case 'Resume':
|
||||
return `cron-job-toggle-${jobId}`;
|
||||
case 'Run Now':
|
||||
return `cron-job-run-${jobId}`;
|
||||
case 'View Runs':
|
||||
return `cron-job-view-runs-${jobId}`;
|
||||
case 'Remove':
|
||||
return `cron-job-remove-${jobId}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCronPanel(timeoutMs = 5_000): Promise<void> {
|
||||
try {
|
||||
await waitForTestId('cron-jobs-panel', timeoutMs);
|
||||
} catch (error) {
|
||||
stepLog('cron panel test id unavailable, falling back to visible panel text', error);
|
||||
await waitForText('Scheduled Jobs', timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCronRow(jobId: string, timeoutMs = 10_000): Promise<void> {
|
||||
try {
|
||||
await waitForTestId(`cron-job-row-${jobId}`, timeoutMs);
|
||||
} catch (error) {
|
||||
stepLog(`cron row test id unavailable for ${jobId}, falling back to visible text`, error);
|
||||
await waitForText(jobId, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function clickCronRefresh(): Promise<void> {
|
||||
try {
|
||||
await clickTestId('cron-refresh');
|
||||
} catch (error) {
|
||||
stepLog('cron refresh test id unavailable, falling back to button text', error);
|
||||
await clickNativeButton('Refresh Cron Jobs');
|
||||
}
|
||||
}
|
||||
|
||||
/** Click the action button (Pause | Resume | Remove | …) inside a cron row. */
|
||||
async function clickActionForJob(jobId: string, action: string): Promise<boolean> {
|
||||
const testId = cronActionTestId(jobId, action);
|
||||
if (!testId) return false;
|
||||
try {
|
||||
await clickTestId(testId, 5_000);
|
||||
return true;
|
||||
} catch (error) {
|
||||
stepLog(`test-id click failed for ${action} on ${jobId}, falling back to button text`, error);
|
||||
}
|
||||
try {
|
||||
await clickNativeButton(action, 5_000);
|
||||
return true;
|
||||
} catch (error) {
|
||||
stepLog(`failed to click ${action} for ${jobId}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll for the in-row 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;
|
||||
const testId = `cron-job-toggle-${jobId}`;
|
||||
try {
|
||||
await waitForTestId(testId, Math.min(timeoutMs, 5_000));
|
||||
} catch (error) {
|
||||
stepLog(`toggle test id not found for ${jobId}, falling back to visible label`, error);
|
||||
try {
|
||||
await waitForText(expected, Math.min(timeoutMs, 5_000));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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()
|
||||
);
|
||||
// We care about the toggle button (first one in the row).
|
||||
return labels[0] ?? null;
|
||||
}, jobName);
|
||||
const current = await browser.execute((id: string) => {
|
||||
const button = document.querySelector(`[data-testid="${id}"]`);
|
||||
return button?.textContent?.trim() ?? null;
|
||||
}, testId);
|
||||
if (current === expected) return true;
|
||||
if (await textExists(expected)) return true;
|
||||
await browser.pause(400);
|
||||
}
|
||||
return false;
|
||||
@@ -118,6 +166,7 @@ async function openCronJobsPanel(): Promise<void> {
|
||||
await navigateViaHash('/settings/cron-jobs');
|
||||
await waitForText('Cron Jobs', 10_000);
|
||||
await waitForText('Scheduled Jobs', 5_000);
|
||||
await waitForCronPanel(5_000);
|
||||
}
|
||||
|
||||
describe('Cron jobs settings panel (real UI flow)', () => {
|
||||
@@ -144,11 +193,13 @@ describe('Cron jobs settings panel (real UI flow)', () => {
|
||||
it('the seeded morning_briefing job appears in the Cron Jobs panel', async () => {
|
||||
await openCronJobsPanel();
|
||||
// The seed runs in a detached spawn_blocking task — poll for the row.
|
||||
const present = await waitForAnyText([MORNING_BRIEFING], 20_000);
|
||||
if (!present) {
|
||||
try {
|
||||
await waitForCronRow(MORNING_BRIEFING, 20_000);
|
||||
} catch {
|
||||
stepLog('morning_briefing row never rendered — clicking Refresh and retrying');
|
||||
await clickButton('Refresh Cron Jobs');
|
||||
await clickCronRefresh();
|
||||
await browser.pause(1_500);
|
||||
await waitForCronRow(MORNING_BRIEFING, 10_000);
|
||||
}
|
||||
expect(await textExists(MORNING_BRIEFING)).toBe(true);
|
||||
expect(await textExists('Enabled')).toBe(true);
|
||||
@@ -166,7 +217,7 @@ describe('Cron jobs settings panel (real UI flow)', () => {
|
||||
expect(await textExists('Paused')).toBe(true);
|
||||
|
||||
// Real UI persistence proof: refresh re-reads from the sidecar.
|
||||
await clickButton('Refresh Cron Jobs');
|
||||
await clickCronRefresh();
|
||||
await browser.pause(1_500);
|
||||
const stillResumed = await waitForRowActionLabel(MORNING_BRIEFING, 'Resume', 8_000);
|
||||
expect(stillResumed).toBe(true);
|
||||
|
||||
@@ -99,6 +99,19 @@ Requires Docker Desktop or Colima. The repo is bind-mounted so builds persist be
|
||||
| `hasAppChrome()` | XCUIElementTypeMenuBar | Window handle check |
|
||||
| `dumpAccessibilityTree()` | Accessibility XML | HTML page source |
|
||||
|
||||
### Stable test IDs
|
||||
|
||||
Prefer stable `data-testid` hooks for UI affordances that E2E specs click or poll. Use the taxonomy `<surface>-<element>-<id?>`, for example:
|
||||
|
||||
- `cron-jobs-panel`, `cron-refresh`
|
||||
- `cron-job-row-<jobId>`, `cron-job-toggle-<jobId>`, `cron-job-run-<jobId>`, `cron-job-view-runs-<jobId>`, `cron-job-remove-<jobId>`
|
||||
- `settings-nav-<routeId>`
|
||||
- `skill-row-<skillId>`, `skill-install-<skillId>`, `skill-uninstall-<skillId>`
|
||||
- `thread-row-<threadId>`, `new-thread-button`, `send-message-button`
|
||||
- `onboarding-next-button`
|
||||
|
||||
Use `waitForTestId(testId)` and `clickTestId(testId)` from `element-helpers.ts` when a spec targets one of these hooks. Keep text selectors for user-visible copy assertions, not row/action discovery.
|
||||
|
||||
### Deep link helpers
|
||||
|
||||
`app/test/e2e/helpers/deep-link-helpers.ts` handles auth deep links:
|
||||
|
||||
Reference in New Issue
Block a user