From 838a229564f1ceca117ae1fb1339f52577e80b45 Mon Sep 17 00:00:00 2001 From: Rui Li Date: Thu, 3 Sep 2026 14:10:25 -0700 Subject: [PATCH] feat(project): --test-id-attributes priority list on project create/update Customers who tag their UI with their own DOM attribute (e.g. Akkio's data-element) need exported locators to use those tags. This adds a project-level, ordered attribute list: testsprite project create ... --test-id-attributes data-element,data-testid testsprite project update --test-id-attributes data-element testsprite project update --clear-test-id-attributes Sent as PATCH/POST body `testIdAttributes: string[]` (null to clear), mirroring the per-test `--step-timeout` precedent (flag -> local parser -> typed option -> conditional body key -> renderer line). `project get` renders the list when the backend reports it (presence-keyed like targetUrl). Names are validated client-side (they are interpolated into CSS selectors by the engine). Backend: PATCH /api/cli/v1/projects/:id testIdAttributes (V3-native projects; mirrored projects answer PRECONDITION_FAILED test_id_attributes_native_only). Co-Authored-By: Claude Fable 5 --- DOCUMENTATION.md | 5 +- src/commands/project.test.ts | 71 ++++++++++++++++++++++++ src/commands/project.ts | 104 ++++++++++++++++++++++++++++++++++- 3 files changed, 176 insertions(+), 4 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 0dd16f3..b4d7aa5 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -434,11 +434,14 @@ testsprite test plan put test_xxxxxxxx --steps ./refined.plan.json --dry-run --o #### `testsprite project create` / `project update` -Manage projects from the CLI. Both pre-flight `--url` against local addresses for fast feedback. Projects have **no description field** — `--description` is rejected client-side with a validation error (descriptions live on tests: `test create --description`). `project update` accepts `--name`, `--url`, `--username`, `--password`, `--password-file`, and `--instruction`. +Manage projects from the CLI. Both pre-flight `--url` against local addresses for fast feedback. Projects have **no description field** — `--description` is rejected client-side with a validation error (descriptions live on tests: `test create --description`). `project update` accepts `--name`, `--url`, `--username`, `--password`, `--password-file`, `--instruction`, `--test-id-attributes`, and `--clear-test-id-attributes`. + +`--test-id-attributes ` (also on `project create`) is the project's locator attribute priority list: a comma-separated, ordered set of DOM attributes your app uses as stable test hooks (e.g. `data-element,data-testid`). The execution engine tries them in that order before any other locator strategy when it exports test code, so a tagged element is exported as `page.locator('[data-element="nav.team-selector.trigger-btn"]')`; an attribute whose value is not unique on the page is skipped. `--clear-test-id-attributes` removes the list (engine default: `data-testid`). V3-native projects only — on a V2-mirrored project the backend answers `PRECONDITION_FAILED` (`test_id_attributes_native_only`). ```bash testsprite project create --type frontend --name "Checkout" --url https://staging.example.com testsprite project update proj_xxxxxxxx --name "Checkout v2" +testsprite project update proj_xxxxxxxx --test-id-attributes data-element,data-testid ``` #### `testsprite project delete ` diff --git a/src/commands/project.test.ts b/src/commands/project.test.ts index 0dc4b43..f442232 100644 --- a/src/commands/project.test.ts +++ b/src/commands/project.test.ts @@ -17,6 +17,7 @@ import { runGet, runList, runUpdate, + parseTestIdAttributesFlag, } from './project.js'; const PROJECT_FIXTURE: CliProject = { @@ -1344,6 +1345,60 @@ describe('runUpdate', () => { expect(stderrLines.some(l => l.includes('idem-upd-001'))).toBe(false); }); + it('sends testIdAttributes as an ordered list; --clear sends null', async () => { + const { credentialsPath } = makeCreds(); + const sentBodies: unknown[] = []; + const fetchImpl = (async (_input: Parameters[0], init: RequestInit = {}) => { + if (init.body) sentBodies.push(JSON.parse(init.body as string) as unknown); + return new Response( + JSON.stringify({ projectId: 'proj_abc', updatedFields: ['testIdAttributes'] }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ); + }) as typeof fetch; + const deps = { credentialsPath, fetchImpl, stdout: () => {}, stderr: () => {} }; + const base = { + profile: 'default', + output: 'json' as const, + debug: false, + projectId: 'proj_abc', + }; + + await runUpdate({ ...base, testIdAttributes: ['data-element', 'data-testid'] }, deps); + expect(sentBodies[0]).toEqual({ testIdAttributes: ['data-element', 'data-testid'] }); + + await runUpdate({ ...base, clearTestIdAttributes: true }, deps); + expect(sentBodies[1]).toEqual({ testIdAttributes: null }); + }); + + it('rejects --test-id-attributes together with --clear-test-id-attributes before any request', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }); + await expect( + runUpdate( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'proj_abc', + testIdAttributes: ['data-element'], + clearTestIdAttributes: true, + }, + { + credentialsPath, + fetchImpl: fetchImpl as unknown as typeof fetch, + stdout: () => {}, + stderr: () => {}, + }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('P7 — exits 5 VALIDATION_ERROR when no mutable flag is supplied', async () => { const { credentialsPath } = makeCreds(); const fetchImpl = vi.fn(async () => { @@ -2124,3 +2179,19 @@ describe('dogfood 2026-06-30 — whitespace-only --name is rejected (parity with ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); }); }); + +describe('parseTestIdAttributesFlag', () => { + it('splits, trims, de-duplicates and keeps priority order', () => { + expect( + parseTestIdAttributesFlag(' data-element, data-testid ,data-element', 'test-id-attributes'), + ).toEqual(['data-element', 'data-testid']); + }); + + it('rejects invalid attribute names and empty lists with a VALIDATION_ERROR', () => { + for (const raw of ['bad name', '[data-element]', '', ' , ']) { + expect(() => parseTestIdAttributesFlag(raw, 'test-id-attributes')).toThrowError( + expect.objectContaining({ code: 'VALIDATION_ERROR' }), + ); + } + }); +}); diff --git a/src/commands/project.ts b/src/commands/project.ts index 9e8f852..c5556c8 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -65,6 +65,53 @@ export interface CliProject { * (see the `project create --type backend` note in CLAUDE.md). */ targetUrl?: string | null; + /** + * Project-level test-id attribute priority list (e.g. `['data-element', + * 'data-testid']`). The execution engine tries these DOM attributes, in + * order, before any other locator strategy when it exports test code, so + * customers who tag their UI with their own attribute get + * `page.locator('[data-element="…"]')` locators. Absent on older backends; + * `null`/empty means "not configured" (engine default: `data-testid`). + */ + testIdAttributes?: string[] | null; +} + +/** Attribute names are interpolated into CSS selectors by the engine — keep them plain. */ +const TEST_ID_ATTRIBUTE_NAME = /^[A-Za-z_][\w.:-]*$/; +const TEST_ID_ATTRIBUTES_MAX = 10; + +/** + * Parse `--test-id-attributes `: a comma-separated, ordered list of DOM + * attribute names. Trims, drops empties and duplicates (first occurrence + * wins — order is the priority), rejects invalid names. Exported for tests. + */ +export function parseTestIdAttributesFlag(raw: string, flagName: string): string[] { + const seen = new Set(); + const attrs: string[] = []; + for (const part of raw.split(',')) { + const name = part.trim(); + if (!name) continue; + if (name.length > 64 || !TEST_ID_ATTRIBUTE_NAME.test(name)) { + throw localValidationError( + `--${flagName}: '${name}' is not a valid attribute name (letters, digits, '_', '-', '.', ':'; must not start with a digit).`, + ); + } + if (!seen.has(name)) { + seen.add(name); + attrs.push(name); + } + } + if (attrs.length === 0) { + throw localValidationError( + `--${flagName} needs at least one attribute name, e.g. --${flagName} data-element,data-testid`, + ); + } + if (attrs.length > TEST_ID_ATTRIBUTES_MAX) { + throw localValidationError( + `--${flagName} accepts at most ${TEST_ID_ATTRIBUTES_MAX} attribute names.`, + ); + } + return attrs; } export interface ProjectDeps { @@ -159,6 +206,8 @@ export interface CliCreateProjectRequest { username?: string; password?: string; instruction?: string; + /** Ordered test-id attribute list — see `CliProject.testIdAttributes`. */ + testIdAttributes?: string[]; } /** @@ -198,6 +247,7 @@ interface CreateOptions extends CommonOptions { name: string; targetUrl?: string; description?: string; + testIdAttributes?: string[]; username?: string; password?: string; passwordFile?: string; @@ -301,6 +351,7 @@ export async function runCreate( ...(opts.username !== undefined ? { username: opts.username } : {}), ...(password !== undefined ? { password } : {}), ...(opts.instruction !== undefined ? { instruction: opts.instruction } : {}), + ...(opts.testIdAttributes !== undefined ? { testIdAttributes: opts.testIdAttributes } : {}), }; const client = makeClient(opts, deps); @@ -399,6 +450,9 @@ interface UpdateOptions extends CommonOptions { password?: string; passwordFile?: string; instruction?: string; + /** Ordered test-id attribute list; `clearTestIdAttributes` sends `null` to unset. */ + testIdAttributes?: string[]; + clearTestIdAttributes?: boolean; idempotencyKey?: string; } @@ -433,6 +487,11 @@ export async function runUpdate( }); } + if (opts.testIdAttributes !== undefined && opts.clearTestIdAttributes) { + throw localValidationError( + '--test-id-attributes and --clear-test-id-attributes are mutually exclusive.', + ); + } const passwordSupplied = opts.password !== undefined || opts.passwordFile !== undefined; const mutableFields: Record = { name: opts.name !== undefined, @@ -440,13 +499,15 @@ export async function runUpdate( username: opts.username !== undefined, password: passwordSupplied, instruction: opts.instruction !== undefined, + testIdAttributes: opts.testIdAttributes !== undefined || opts.clearTestIdAttributes === true, }; const presentFieldNames = Object.entries(mutableFields) .filter(([, present]) => present) .map(([field]) => field); if (presentFieldNames.length === 0) { throw localValidationError( - 'At least one mutable flag is required: --name, --url, --username, --password/--password-file, or --instruction.', + 'At least one mutable flag is required: --name, --url, --username, --password/--password-file, ' + + '--instruction, --test-id-attributes, or --clear-test-id-attributes.', ); } @@ -485,16 +546,18 @@ export async function runUpdate( stderr(`idempotency-key: ${idempotencyKey}`); } - const bodyFields: Record = { + const bodyFields: Record = { name: opts.name, targetUrl: opts.targetUrl, username: opts.username, password, instruction: opts.instruction, + // `null` clears the list server-side (same convention as `test update --clear-step-timeout`). + testIdAttributes: opts.clearTestIdAttributes ? null : opts.testIdAttributes, }; const body = Object.fromEntries( Object.entries(bodyFields).filter(([, v]) => v !== undefined), - ) as Record; + ) as Record; const client = makeClient(opts, deps); const rawUpdated = await client.patch( `/projects/${encodeURIComponent(opts.projectId)}`, @@ -1336,6 +1399,11 @@ export function createProjectCommand(deps: ProjectDeps = {}): Command { .option('--password ', 'optional auth password (use --password-file for non-interactive)') .option('--password-file ', 'read password from file instead of inline flag') .option('--instruction ', 'optional FE plan-gen instruction hint') + .option( + '--test-id-attributes ', + 'comma-separated DOM attributes the engine should prefer as locators, highest priority first ' + + '(e.g. data-element,data-testid). Unique values are exported as page.locator(\'[attr="…"]\').', + ) .option( '--idempotency-key ', 'opaque idempotency token. Defaults to a UUIDv4 minted per invocation.', @@ -1362,6 +1430,10 @@ export function createProjectCommand(deps: ProjectDeps = {}): Command { password: cmdOpts.password, passwordFile: cmdOpts.passwordFile, instruction: cmdOpts.instruction, + testIdAttributes: + cmdOpts.testIdAttributes !== undefined + ? parseTestIdAttributesFlag(cmdOpts.testIdAttributes, 'test-id-attributes') + : undefined, idempotencyKey: cmdOpts.idempotencyKey, }, deps, @@ -1377,6 +1449,16 @@ export function createProjectCommand(deps: ProjectDeps = {}): Command { .option('--password ', 'new auth password') .option('--password-file ', 'read new password from file') .option('--instruction ', 'new FE plan-gen instruction hint') + .option( + '--test-id-attributes ', + 'comma-separated DOM attributes the engine should prefer as locators, highest priority first ' + + '(e.g. data-element,data-testid); replaces the current list', + ) + .option( + '--clear-test-id-attributes', + 'remove the test-id attribute list (engine falls back to data-testid)', + false, + ) .option( '--idempotency-key ', 'opaque idempotency token. Defaults to a UUIDv4 minted per invocation.', @@ -1393,6 +1475,11 @@ export function createProjectCommand(deps: ProjectDeps = {}): Command { password: cmdOpts.password, passwordFile: cmdOpts.passwordFile, instruction: cmdOpts.instruction, + testIdAttributes: + cmdOpts.testIdAttributes !== undefined + ? parseTestIdAttributesFlag(cmdOpts.testIdAttributes, 'test-id-attributes') + : undefined, + clearTestIdAttributes: cmdOpts.clearTestIdAttributes, idempotencyKey: cmdOpts.idempotencyKey, }, deps, @@ -1589,6 +1676,7 @@ interface CreateFlagOpts { password?: string; passwordFile?: string; instruction?: string; + testIdAttributes?: string; idempotencyKey?: string; } @@ -1599,6 +1687,8 @@ interface UpdateFlagOpts { password?: string; passwordFile?: string; instruction?: string; + testIdAttributes?: string; + clearTestIdAttributes?: boolean; idempotencyKey?: string; } @@ -1776,6 +1866,14 @@ function renderProjectText(p: CliProject): string { : `targetUrl: (not set — set one with: testsprite project update ${p.id} --url )`, ); } + // Presence-keyed like targetUrl: older backends don't report the field at all. + if ('testIdAttributes' in p) { + lines.push( + p.testIdAttributes && p.testIdAttributes.length > 0 + ? `testIdAttrs: ${p.testIdAttributes.join(', ')} (locator priority, highest first)` + : `testIdAttrs: (not set — engine default data-testid; set with: testsprite project update ${p.id} --test-id-attributes )`, + ); + } return lines.join('\n'); }