@firu-daniel/agent-qa-attrs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/chunk-GBT5D3KO.js +34 -0
- package/dist/chunk-GBT5D3KO.js.map +1 -0
- package/dist/core/index.cjs +39 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.js +3 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/qaAttr.d.ts +79 -0
- package/dist/core/qaSelector.d.ts +20 -0
- package/dist/index.cjs +56 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/react/QaScope.d.ts +49 -0
- package/dist/react/index.d.ts +6 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Firu Daniel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# `@firu-daniel/agent-qa-attrs`
|
|
2
|
+
|
|
3
|
+
A `data-qa-*` attribute contract that makes a React application deterministically drivable — by an
|
|
4
|
+
end-to-end test, and by a browser-driving agent that has to find an element without being told where
|
|
5
|
+
it is on screen.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i @firu-daniel/agent-qa-attrs
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Why an attribute contract rather than test ids scattered by hand
|
|
12
|
+
|
|
13
|
+
A test that clicks `button.primary:nth-child(2)` breaks on a layout change. A test that clicks the
|
|
14
|
+
button reading "Continue" breaks on a copy change, and breaks in every locale but one. Both failures
|
|
15
|
+
look like a broken feature and are not one.
|
|
16
|
+
|
|
17
|
+
The alternative is an attribute whose only job is to be stable. That part is well understood. What is
|
|
18
|
+
not is keeping the two sides of it honest: the component that emits the attribute and the test that
|
|
19
|
+
looks it up have to agree on the attribute's name, on what an absent value means, and on how an id is
|
|
20
|
+
composed — and nothing checks that agreement when each side spells it out separately.
|
|
21
|
+
|
|
22
|
+
This package is that agreement, written once.
|
|
23
|
+
|
|
24
|
+
## The three families
|
|
25
|
+
|
|
26
|
+
| Attribute | Carries | Use it when |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `data-qa-id` | A stable, human-readable element identifier | Anything a test refers to by name |
|
|
29
|
+
| `data-qa-value` | The asserted value of an element | Visible text is formatted, localized, or otherwise not a reliable assertion target |
|
|
30
|
+
| `data-qa-status` | A coarse lifecycle token — `loading`, `ready`, `error` | A test must wait for a state rather than guess on a spinner |
|
|
31
|
+
|
|
32
|
+
There is no fourth family, and adding one is a change to this package rather than a new attribute
|
|
33
|
+
name invented at a call site.
|
|
34
|
+
|
|
35
|
+
## Emitting them
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import { qaAttr } from '@firu-daniel/agent-qa-attrs';
|
|
39
|
+
|
|
40
|
+
<button {...qaAttr({ id: 'login-submit' })}>Continue</button>
|
|
41
|
+
<span {...qaAttr({ id: 'balance', value: balance })}>{formatted}</span>
|
|
42
|
+
<div {...qaAttr({ id: 'upload', status: 'ready' })} />
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
An option that is not given produces no attribute at all — never an empty-string one, so a test can
|
|
46
|
+
distinguish "no status" from "empty status". `0` and `''` are values like any other: the check is
|
|
47
|
+
against `undefined`, never against falsiness.
|
|
48
|
+
|
|
49
|
+
## Scoping ids to their feature
|
|
50
|
+
|
|
51
|
+
Every id carries the area it belongs to, so two features never collide on a bare `submit`. Typing
|
|
52
|
+
that prefix at each call site is a rule someone has to remember; a scope makes it structural.
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import { QaScope, useQaAttr } from '@firu-daniel/agent-qa-attrs';
|
|
56
|
+
|
|
57
|
+
const Submit = () => <button {...useQaAttr({ id: 'submit' })}>Continue</button>;
|
|
58
|
+
|
|
59
|
+
<QaScope name="login">
|
|
60
|
+
<Submit /> {/* data-qa-id="login-submit" */}
|
|
61
|
+
</QaScope>;
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Scopes nest and extend rather than replace, so a component that carries its own scope can be mounted
|
|
65
|
+
anywhere: a `list` scope inside a `wallet` scope tags `wallet-list-row-3`. `QaScope` renders no
|
|
66
|
+
element of its own, so dropping one into a layout cannot change the DOM a test is driving.
|
|
67
|
+
|
|
68
|
+
## Reading them back
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { qaSelector } from '@firu-daniel/agent-qa-attrs/core';
|
|
72
|
+
|
|
73
|
+
await page.click(qaSelector({ id: 'login-submit' }));
|
|
74
|
+
await page.waitForSelector(qaSelector({ id: 'upload', status: 'ready' }));
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`qaSelector` builds the selector matching exactly what `qaAttr` emits for the same options, so the
|
|
78
|
+
two sides cannot drift: when the contract changes, a hand-written `[data-qa-id="…"]` keeps compiling
|
|
79
|
+
and silently stops matching, and this does not.
|
|
80
|
+
|
|
81
|
+
The `/core` entry point is framework-agnostic and pulls no React — it is what a test runner, a Node
|
|
82
|
+
script or a non-React application imports.
|
|
83
|
+
|
|
84
|
+
## Shipping them to production
|
|
85
|
+
|
|
86
|
+
These attributes are inert `data-*` attributes. They carry no logic, affect no rendering, and are
|
|
87
|
+
deliberately **not** stripped behind an environment flag: an attribute that exists only in a test
|
|
88
|
+
build cannot be relied on by a test that drives the real one.
|
|
89
|
+
|
|
90
|
+
Their values are test tokens rather than user-visible copy — hardcoded identifier strings, exempt
|
|
91
|
+
from localization by design.
|
|
92
|
+
|
|
93
|
+
## API
|
|
94
|
+
|
|
95
|
+
| Export | Entry point | What it does |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `qaAttr(options)` | root, `/core` | Builds the attribute bag to spread onto an element |
|
|
98
|
+
| `qaSelector(options)` | root, `/core` | Builds the CSS selector matching what `qaAttr` emits |
|
|
99
|
+
| `joinQaId(prefix, id)` | root, `/core` | Joins an area prefix to a local id |
|
|
100
|
+
| `QA_ATTRIBUTE` | root, `/core` | The three attribute names, spelled once |
|
|
101
|
+
| `QaScope` | root | Declares the area every id beneath it belongs to |
|
|
102
|
+
| `useQaAttr(options)` | root | `qaAttr` with the enclosing scope applied |
|
|
103
|
+
| `useQaScope()` | root | The prefix in effect at this point in the tree |
|
|
104
|
+
|
|
105
|
+
MIT © firu-daniel
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/core/qaAttr.ts
|
|
2
|
+
var QA_ATTRIBUTE = {
|
|
3
|
+
id: "data-qa-id",
|
|
4
|
+
value: "data-qa-value",
|
|
5
|
+
status: "data-qa-status"
|
|
6
|
+
};
|
|
7
|
+
var qaAttr = (options) => {
|
|
8
|
+
const attributes = { [QA_ATTRIBUTE.id]: options.id };
|
|
9
|
+
if (options.value !== void 0) {
|
|
10
|
+
attributes[QA_ATTRIBUTE.value] = String(options.value);
|
|
11
|
+
}
|
|
12
|
+
if (options.status !== void 0) {
|
|
13
|
+
attributes[QA_ATTRIBUTE.status] = options.status;
|
|
14
|
+
}
|
|
15
|
+
return attributes;
|
|
16
|
+
};
|
|
17
|
+
var joinQaId = (prefix, id) => prefix === void 0 || prefix === "" ? id : `${prefix}-${id}`;
|
|
18
|
+
|
|
19
|
+
// src/core/qaSelector.ts
|
|
20
|
+
var escapeAttributeValue = (value) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
21
|
+
var qaSelector = (options) => {
|
|
22
|
+
let selector = `[${QA_ATTRIBUTE.id}="${escapeAttributeValue(options.id)}"]`;
|
|
23
|
+
if (options.value !== void 0) {
|
|
24
|
+
selector += `[${QA_ATTRIBUTE.value}="${escapeAttributeValue(String(options.value))}"]`;
|
|
25
|
+
}
|
|
26
|
+
if (options.status !== void 0) {
|
|
27
|
+
selector += `[${QA_ATTRIBUTE.status}="${escapeAttributeValue(options.status)}"]`;
|
|
28
|
+
}
|
|
29
|
+
return selector;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export { QA_ATTRIBUTE, joinQaId, qaAttr, qaSelector };
|
|
33
|
+
//# sourceMappingURL=chunk-GBT5D3KO.js.map
|
|
34
|
+
//# sourceMappingURL=chunk-GBT5D3KO.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/qaAttr.ts","../src/core/qaSelector.ts"],"names":[],"mappings":";AA6CO,IAAM,YAAA,GAAe;AAAA,EAC1B,EAAA,EAAI,YAAA;AAAA,EACJ,KAAA,EAAO,eAAA;AAAA,EACP,MAAA,EAAQ;AACV;AAwBO,IAAM,MAAA,GAAS,CAAC,OAAA,KAAyC;AAC9D,EAAA,MAAM,aAA2B,EAAE,CAAC,aAAa,EAAE,GAAG,QAAQ,EAAA,EAAG;AAEjE,EAAA,IAAI,OAAA,CAAQ,UAAU,MAAA,EAAW;AAC/B,IAAA,UAAA,CAAW,YAAA,CAAa,KAAK,CAAA,GAAI,MAAA,CAAO,QAAQ,KAAK,CAAA;AAAA,EACvD;AAEA,EAAA,IAAI,OAAA,CAAQ,WAAW,MAAA,EAAW;AAChC,IAAA,UAAA,CAAW,YAAA,CAAa,MAAM,CAAA,GAAI,OAAA,CAAQ,MAAA;AAAA,EAC5C;AAEA,EAAA,OAAO,UAAA;AACT;AAUO,IAAM,QAAA,GAAW,CAAC,MAAA,EAA4B,EAAA,KACnD,MAAA,KAAW,MAAA,IAAa,MAAA,KAAW,EAAA,GAAK,EAAA,GAAK,CAAA,EAAG,MAAM,CAAA,CAAA,EAAI,EAAE,CAAA;;;ACnF9D,IAAM,oBAAA,GAAuB,CAAC,KAAA,KAA0B,KAAA,CAAM,OAAA,CAAQ,OAAO,MAAM,CAAA,CAAE,OAAA,CAAQ,IAAA,EAAM,KAAK,CAAA;AAWjG,IAAM,UAAA,GAAa,CAAC,OAAA,KAAmC;AAC5D,EAAA,IAAI,QAAA,GAAW,IAAI,YAAA,CAAa,EAAE,KAAK,oBAAA,CAAqB,OAAA,CAAQ,EAAE,CAAC,CAAA,EAAA,CAAA;AAEvE,EAAA,IAAI,OAAA,CAAQ,UAAU,MAAA,EAAW;AAC/B,IAAA,QAAA,IAAY,CAAA,CAAA,EAAI,aAAa,KAAK,CAAA,EAAA,EAAK,qBAAqB,MAAA,CAAO,OAAA,CAAQ,KAAK,CAAC,CAAC,CAAA,EAAA,CAAA;AAAA,EACpF;AAEA,EAAA,IAAI,OAAA,CAAQ,WAAW,MAAA,EAAW;AAChC,IAAA,QAAA,IAAY,IAAI,YAAA,CAAa,MAAM,KAAK,oBAAA,CAAqB,OAAA,CAAQ,MAAM,CAAC,CAAA,EAAA,CAAA;AAAA,EAC9E;AAEA,EAAA,OAAO,QAAA;AACT","file":"chunk-GBT5D3KO.js","sourcesContent":["/**\n * The `data-qa-*` attribute contract, and the helper that emits it.\n *\n * This is a test affordance, not a styling or behaviour hook: a single,\n * documented way to tag DOM elements with deterministic attributes so a\n * browser-driving agent or an end-to-end test can locate elements, read their\n * state, and assert on values without relying on brittle CSS selectors or on\n * matching user-visible text. A test plan written in terms of these ids is a\n * contract between whoever writes the plan and whoever writes the component.\n *\n * ## The three attribute families (all prefixed `data-qa-`)\n *\n * - `data-qa-id=\"<stable-element-id>\"` — a stable, human-readable identifier\n * for an element a test needs to find or click (`\"login-submit\"`,\n * `\"message-input\"`). Keep it unique within a page; for list rows, suffix the\n * entity id (`\"message-<messageId>\"`).\n * - `data-qa-value=\"<value>\"` — the asserted value of an element when the\n * visible text is not a reliable assertion target (a formatted balance, a\n * localized label). Use it when the test must read state, not just presence.\n * - `data-qa-status=\"<state>\"` — a coarse lifecycle token for stateful widgets\n * (`\"loading\" | \"ready\" | \"error\"`), so a test can wait for a known token\n * rather than guessing on a spinner.\n *\n * ## Rules\n *\n * - The attributes are **additive and inert**: they never carry business logic,\n * never affect rendering, and are safe to ship to production. They are\n * deliberately *not* stripped behind an environment flag — an attribute that\n * exists only in a test build cannot be relied on by a test that drives the\n * real one.\n * - Only these three families. A new need extends the contract by a documented\n * change here, never by a one-off attribute name invented at a call site.\n * - The values are test tokens, not user-visible copy. They are intentionally\n * hardcoded identifier strings and are exempt from localization by design.\n *\n * ## Usage\n *\n * ```tsx\n * <button {...qaAttr({ id: 'login-submit' })}>Continue</button>\n * <span {...qaAttr({ id: 'balance', value: balance })}>{formatted}</span>\n * <div {...qaAttr({ id: 'upload', status: 'ready' })} />\n * ```\n */\n\n/** The three attribute names this contract defines, spelled once. */\nexport const QA_ATTRIBUTE = {\n id: 'data-qa-id',\n value: 'data-qa-value',\n status: 'data-qa-status',\n} as const;\n\n/** What {@link qaAttr} accepts. Only `id` is required; the rest are emitted when present. */\nexport interface QaAttrOptions {\n /** Stable, human-readable element identifier (`data-qa-id`). */\n id: string;\n /** Asserted value when visible text is not a reliable target (`data-qa-value`). */\n value?: string | number;\n /** Coarse lifecycle token, e.g. `loading` | `ready` | `error` (`data-qa-status`). */\n status?: string;\n}\n\n/** The emitted attribute bag: attribute name to string value, ready to spread onto an element. */\nexport type QaAttributes = Record<string, string>;\n\n/**\n * Build the `data-qa-*` attribute bag to spread onto an element.\n *\n * Only the keys whose option was provided are emitted, so an absent option\n * produces no attribute rather than an empty-string one — a test asserting on\n * `data-qa-status` must be able to distinguish \"no status\" from \"empty status\".\n * `value` is stringified, and `0` and `''` are values like any other: the check\n * is against `undefined`, never against falsiness.\n */\nexport const qaAttr = (options: QaAttrOptions): QaAttributes => {\n const attributes: QaAttributes = { [QA_ATTRIBUTE.id]: options.id };\n\n if (options.value !== undefined) {\n attributes[QA_ATTRIBUTE.value] = String(options.value);\n }\n\n if (options.status !== undefined) {\n attributes[QA_ATTRIBUTE.status] = options.status;\n }\n\n return attributes;\n};\n\n/**\n * Join an area prefix and a local id into one contract-shaped id.\n *\n * The naming rule is that an id carries the feature area it belongs to, so two\n * features never collide on a bare `\"submit\"`. An empty or absent prefix\n * returns the local id unchanged, which is what makes an unscoped call site and\n * a scoped one produce the same shape.\n */\nexport const joinQaId = (prefix: string | undefined, id: string): string =>\n prefix === undefined || prefix === '' ? id : `${prefix}-${id}`;\n","/**\n * The read side of the `data-qa-*` contract: turning the attributes a component\n * emits into the selector a test or a browser-driving agent looks them up by.\n *\n * It exists so the two sides cannot drift. A test that hand-writes\n * `[data-qa-id=\"login-submit\"]` has copied an attribute name that only\n * `qaAttr` should know; when the contract gains or renames a family, the\n * hand-written selector keeps compiling and silently stops matching.\n */\n\nimport { QA_ATTRIBUTE, type QaAttrOptions } from './qaAttr.js';\n\n/** Escape a value for use inside a CSS attribute selector's quoted string. */\nconst escapeAttributeValue = (value: string): string => value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"');\n\n/**\n * Build a CSS attribute selector matching the elements {@link qaAttr} would\n * emit for the same options.\n *\n * Every provided option narrows the selector, in the contract's own order, so\n * `{ id: 'upload', status: 'ready' }` yields\n * `[data-qa-id=\"upload\"][data-qa-status=\"ready\"]` — the \"wait until this\n * element reaches this state\" query, expressed once.\n */\nexport const qaSelector = (options: QaAttrOptions): string => {\n let selector = `[${QA_ATTRIBUTE.id}=\"${escapeAttributeValue(options.id)}\"]`;\n\n if (options.value !== undefined) {\n selector += `[${QA_ATTRIBUTE.value}=\"${escapeAttributeValue(String(options.value))}\"]`;\n }\n\n if (options.status !== undefined) {\n selector += `[${QA_ATTRIBUTE.status}=\"${escapeAttributeValue(options.status)}\"]`;\n }\n\n return selector;\n};\n"]}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/core/qaAttr.ts
|
|
4
|
+
var QA_ATTRIBUTE = {
|
|
5
|
+
id: "data-qa-id",
|
|
6
|
+
value: "data-qa-value",
|
|
7
|
+
status: "data-qa-status"
|
|
8
|
+
};
|
|
9
|
+
var qaAttr = (options) => {
|
|
10
|
+
const attributes = { [QA_ATTRIBUTE.id]: options.id };
|
|
11
|
+
if (options.value !== void 0) {
|
|
12
|
+
attributes[QA_ATTRIBUTE.value] = String(options.value);
|
|
13
|
+
}
|
|
14
|
+
if (options.status !== void 0) {
|
|
15
|
+
attributes[QA_ATTRIBUTE.status] = options.status;
|
|
16
|
+
}
|
|
17
|
+
return attributes;
|
|
18
|
+
};
|
|
19
|
+
var joinQaId = (prefix, id) => prefix === void 0 || prefix === "" ? id : `${prefix}-${id}`;
|
|
20
|
+
|
|
21
|
+
// src/core/qaSelector.ts
|
|
22
|
+
var escapeAttributeValue = (value) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
23
|
+
var qaSelector = (options) => {
|
|
24
|
+
let selector = `[${QA_ATTRIBUTE.id}="${escapeAttributeValue(options.id)}"]`;
|
|
25
|
+
if (options.value !== void 0) {
|
|
26
|
+
selector += `[${QA_ATTRIBUTE.value}="${escapeAttributeValue(String(options.value))}"]`;
|
|
27
|
+
}
|
|
28
|
+
if (options.status !== void 0) {
|
|
29
|
+
selector += `[${QA_ATTRIBUTE.status}="${escapeAttributeValue(options.status)}"]`;
|
|
30
|
+
}
|
|
31
|
+
return selector;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
exports.QA_ATTRIBUTE = QA_ATTRIBUTE;
|
|
35
|
+
exports.joinQaId = joinQaId;
|
|
36
|
+
exports.qaAttr = qaAttr;
|
|
37
|
+
exports.qaSelector = qaSelector;
|
|
38
|
+
//# sourceMappingURL=index.cjs.map
|
|
39
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/qaAttr.ts","../../src/core/qaSelector.ts"],"names":[],"mappings":";;;AA6CO,IAAM,YAAA,GAAe;AAAA,EAC1B,EAAA,EAAI,YAAA;AAAA,EACJ,KAAA,EAAO,eAAA;AAAA,EACP,MAAA,EAAQ;AACV;AAwBO,IAAM,MAAA,GAAS,CAAC,OAAA,KAAyC;AAC9D,EAAA,MAAM,aAA2B,EAAE,CAAC,aAAa,EAAE,GAAG,QAAQ,EAAA,EAAG;AAEjE,EAAA,IAAI,OAAA,CAAQ,UAAU,MAAA,EAAW;AAC/B,IAAA,UAAA,CAAW,YAAA,CAAa,KAAK,CAAA,GAAI,MAAA,CAAO,QAAQ,KAAK,CAAA;AAAA,EACvD;AAEA,EAAA,IAAI,OAAA,CAAQ,WAAW,MAAA,EAAW;AAChC,IAAA,UAAA,CAAW,YAAA,CAAa,MAAM,CAAA,GAAI,OAAA,CAAQ,MAAA;AAAA,EAC5C;AAEA,EAAA,OAAO,UAAA;AACT;AAUO,IAAM,QAAA,GAAW,CAAC,MAAA,EAA4B,EAAA,KACnD,MAAA,KAAW,MAAA,IAAa,MAAA,KAAW,EAAA,GAAK,EAAA,GAAK,CAAA,EAAG,MAAM,CAAA,CAAA,EAAI,EAAE,CAAA;;;ACnF9D,IAAM,oBAAA,GAAuB,CAAC,KAAA,KAA0B,KAAA,CAAM,OAAA,CAAQ,OAAO,MAAM,CAAA,CAAE,OAAA,CAAQ,IAAA,EAAM,KAAK,CAAA;AAWjG,IAAM,UAAA,GAAa,CAAC,OAAA,KAAmC;AAC5D,EAAA,IAAI,QAAA,GAAW,IAAI,YAAA,CAAa,EAAE,KAAK,oBAAA,CAAqB,OAAA,CAAQ,EAAE,CAAC,CAAA,EAAA,CAAA;AAEvE,EAAA,IAAI,OAAA,CAAQ,UAAU,MAAA,EAAW;AAC/B,IAAA,QAAA,IAAY,CAAA,CAAA,EAAI,aAAa,KAAK,CAAA,EAAA,EAAK,qBAAqB,MAAA,CAAO,OAAA,CAAQ,KAAK,CAAC,CAAC,CAAA,EAAA,CAAA;AAAA,EACpF;AAEA,EAAA,IAAI,OAAA,CAAQ,WAAW,MAAA,EAAW;AAChC,IAAA,QAAA,IAAY,IAAI,YAAA,CAAa,MAAM,KAAK,oBAAA,CAAqB,OAAA,CAAQ,MAAM,CAAC,CAAA,EAAA,CAAA;AAAA,EAC9E;AAEA,EAAA,OAAO,QAAA;AACT","file":"index.cjs","sourcesContent":["/**\n * The `data-qa-*` attribute contract, and the helper that emits it.\n *\n * This is a test affordance, not a styling or behaviour hook: a single,\n * documented way to tag DOM elements with deterministic attributes so a\n * browser-driving agent or an end-to-end test can locate elements, read their\n * state, and assert on values without relying on brittle CSS selectors or on\n * matching user-visible text. A test plan written in terms of these ids is a\n * contract between whoever writes the plan and whoever writes the component.\n *\n * ## The three attribute families (all prefixed `data-qa-`)\n *\n * - `data-qa-id=\"<stable-element-id>\"` — a stable, human-readable identifier\n * for an element a test needs to find or click (`\"login-submit\"`,\n * `\"message-input\"`). Keep it unique within a page; for list rows, suffix the\n * entity id (`\"message-<messageId>\"`).\n * - `data-qa-value=\"<value>\"` — the asserted value of an element when the\n * visible text is not a reliable assertion target (a formatted balance, a\n * localized label). Use it when the test must read state, not just presence.\n * - `data-qa-status=\"<state>\"` — a coarse lifecycle token for stateful widgets\n * (`\"loading\" | \"ready\" | \"error\"`), so a test can wait for a known token\n * rather than guessing on a spinner.\n *\n * ## Rules\n *\n * - The attributes are **additive and inert**: they never carry business logic,\n * never affect rendering, and are safe to ship to production. They are\n * deliberately *not* stripped behind an environment flag — an attribute that\n * exists only in a test build cannot be relied on by a test that drives the\n * real one.\n * - Only these three families. A new need extends the contract by a documented\n * change here, never by a one-off attribute name invented at a call site.\n * - The values are test tokens, not user-visible copy. They are intentionally\n * hardcoded identifier strings and are exempt from localization by design.\n *\n * ## Usage\n *\n * ```tsx\n * <button {...qaAttr({ id: 'login-submit' })}>Continue</button>\n * <span {...qaAttr({ id: 'balance', value: balance })}>{formatted}</span>\n * <div {...qaAttr({ id: 'upload', status: 'ready' })} />\n * ```\n */\n\n/** The three attribute names this contract defines, spelled once. */\nexport const QA_ATTRIBUTE = {\n id: 'data-qa-id',\n value: 'data-qa-value',\n status: 'data-qa-status',\n} as const;\n\n/** What {@link qaAttr} accepts. Only `id` is required; the rest are emitted when present. */\nexport interface QaAttrOptions {\n /** Stable, human-readable element identifier (`data-qa-id`). */\n id: string;\n /** Asserted value when visible text is not a reliable target (`data-qa-value`). */\n value?: string | number;\n /** Coarse lifecycle token, e.g. `loading` | `ready` | `error` (`data-qa-status`). */\n status?: string;\n}\n\n/** The emitted attribute bag: attribute name to string value, ready to spread onto an element. */\nexport type QaAttributes = Record<string, string>;\n\n/**\n * Build the `data-qa-*` attribute bag to spread onto an element.\n *\n * Only the keys whose option was provided are emitted, so an absent option\n * produces no attribute rather than an empty-string one — a test asserting on\n * `data-qa-status` must be able to distinguish \"no status\" from \"empty status\".\n * `value` is stringified, and `0` and `''` are values like any other: the check\n * is against `undefined`, never against falsiness.\n */\nexport const qaAttr = (options: QaAttrOptions): QaAttributes => {\n const attributes: QaAttributes = { [QA_ATTRIBUTE.id]: options.id };\n\n if (options.value !== undefined) {\n attributes[QA_ATTRIBUTE.value] = String(options.value);\n }\n\n if (options.status !== undefined) {\n attributes[QA_ATTRIBUTE.status] = options.status;\n }\n\n return attributes;\n};\n\n/**\n * Join an area prefix and a local id into one contract-shaped id.\n *\n * The naming rule is that an id carries the feature area it belongs to, so two\n * features never collide on a bare `\"submit\"`. An empty or absent prefix\n * returns the local id unchanged, which is what makes an unscoped call site and\n * a scoped one produce the same shape.\n */\nexport const joinQaId = (prefix: string | undefined, id: string): string =>\n prefix === undefined || prefix === '' ? id : `${prefix}-${id}`;\n","/**\n * The read side of the `data-qa-*` contract: turning the attributes a component\n * emits into the selector a test or a browser-driving agent looks them up by.\n *\n * It exists so the two sides cannot drift. A test that hand-writes\n * `[data-qa-id=\"login-submit\"]` has copied an attribute name that only\n * `qaAttr` should know; when the contract gains or renames a family, the\n * hand-written selector keeps compiling and silently stops matching.\n */\n\nimport { QA_ATTRIBUTE, type QaAttrOptions } from './qaAttr.js';\n\n/** Escape a value for use inside a CSS attribute selector's quoted string. */\nconst escapeAttributeValue = (value: string): string => value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"');\n\n/**\n * Build a CSS attribute selector matching the elements {@link qaAttr} would\n * emit for the same options.\n *\n * Every provided option narrows the selector, in the contract's own order, so\n * `{ id: 'upload', status: 'ready' }` yields\n * `[data-qa-id=\"upload\"][data-qa-status=\"ready\"]` — the \"wait until this\n * element reaches this state\" query, expressed once.\n */\nexport const qaSelector = (options: QaAttrOptions): string => {\n let selector = `[${QA_ATTRIBUTE.id}=\"${escapeAttributeValue(options.id)}\"]`;\n\n if (options.value !== undefined) {\n selector += `[${QA_ATTRIBUTE.value}=\"${escapeAttributeValue(String(options.value))}\"]`;\n }\n\n if (options.status !== undefined) {\n selector += `[${QA_ATTRIBUTE.status}=\"${escapeAttributeValue(options.status)}\"]`;\n }\n\n return selector;\n};\n"]}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework-agnostic half of the contract: the attribute names, the helper
|
|
3
|
+
* that emits them, and the selector that reads them back. Nothing here imports
|
|
4
|
+
* a rendering library, so a test runner, a Node script or a non-React
|
|
5
|
+
* application can depend on `@firu-daniel/agent-qa-attrs/core` alone.
|
|
6
|
+
*/
|
|
7
|
+
export { QA_ATTRIBUTE, qaAttr, joinQaId } from './qaAttr.js';
|
|
8
|
+
export type { QaAttrOptions, QaAttributes } from './qaAttr.js';
|
|
9
|
+
export { qaSelector } from './qaSelector.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `data-qa-*` attribute contract, and the helper that emits it.
|
|
3
|
+
*
|
|
4
|
+
* This is a test affordance, not a styling or behaviour hook: a single,
|
|
5
|
+
* documented way to tag DOM elements with deterministic attributes so a
|
|
6
|
+
* browser-driving agent or an end-to-end test can locate elements, read their
|
|
7
|
+
* state, and assert on values without relying on brittle CSS selectors or on
|
|
8
|
+
* matching user-visible text. A test plan written in terms of these ids is a
|
|
9
|
+
* contract between whoever writes the plan and whoever writes the component.
|
|
10
|
+
*
|
|
11
|
+
* ## The three attribute families (all prefixed `data-qa-`)
|
|
12
|
+
*
|
|
13
|
+
* - `data-qa-id="<stable-element-id>"` — a stable, human-readable identifier
|
|
14
|
+
* for an element a test needs to find or click (`"login-submit"`,
|
|
15
|
+
* `"message-input"`). Keep it unique within a page; for list rows, suffix the
|
|
16
|
+
* entity id (`"message-<messageId>"`).
|
|
17
|
+
* - `data-qa-value="<value>"` — the asserted value of an element when the
|
|
18
|
+
* visible text is not a reliable assertion target (a formatted balance, a
|
|
19
|
+
* localized label). Use it when the test must read state, not just presence.
|
|
20
|
+
* - `data-qa-status="<state>"` — a coarse lifecycle token for stateful widgets
|
|
21
|
+
* (`"loading" | "ready" | "error"`), so a test can wait for a known token
|
|
22
|
+
* rather than guessing on a spinner.
|
|
23
|
+
*
|
|
24
|
+
* ## Rules
|
|
25
|
+
*
|
|
26
|
+
* - The attributes are **additive and inert**: they never carry business logic,
|
|
27
|
+
* never affect rendering, and are safe to ship to production. They are
|
|
28
|
+
* deliberately *not* stripped behind an environment flag — an attribute that
|
|
29
|
+
* exists only in a test build cannot be relied on by a test that drives the
|
|
30
|
+
* real one.
|
|
31
|
+
* - Only these three families. A new need extends the contract by a documented
|
|
32
|
+
* change here, never by a one-off attribute name invented at a call site.
|
|
33
|
+
* - The values are test tokens, not user-visible copy. They are intentionally
|
|
34
|
+
* hardcoded identifier strings and are exempt from localization by design.
|
|
35
|
+
*
|
|
36
|
+
* ## Usage
|
|
37
|
+
*
|
|
38
|
+
* ```tsx
|
|
39
|
+
* <button {...qaAttr({ id: 'login-submit' })}>Continue</button>
|
|
40
|
+
* <span {...qaAttr({ id: 'balance', value: balance })}>{formatted}</span>
|
|
41
|
+
* <div {...qaAttr({ id: 'upload', status: 'ready' })} />
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
/** The three attribute names this contract defines, spelled once. */
|
|
45
|
+
export declare const QA_ATTRIBUTE: {
|
|
46
|
+
readonly id: "data-qa-id";
|
|
47
|
+
readonly value: "data-qa-value";
|
|
48
|
+
readonly status: "data-qa-status";
|
|
49
|
+
};
|
|
50
|
+
/** What {@link qaAttr} accepts. Only `id` is required; the rest are emitted when present. */
|
|
51
|
+
export interface QaAttrOptions {
|
|
52
|
+
/** Stable, human-readable element identifier (`data-qa-id`). */
|
|
53
|
+
id: string;
|
|
54
|
+
/** Asserted value when visible text is not a reliable target (`data-qa-value`). */
|
|
55
|
+
value?: string | number;
|
|
56
|
+
/** Coarse lifecycle token, e.g. `loading` | `ready` | `error` (`data-qa-status`). */
|
|
57
|
+
status?: string;
|
|
58
|
+
}
|
|
59
|
+
/** The emitted attribute bag: attribute name to string value, ready to spread onto an element. */
|
|
60
|
+
export type QaAttributes = Record<string, string>;
|
|
61
|
+
/**
|
|
62
|
+
* Build the `data-qa-*` attribute bag to spread onto an element.
|
|
63
|
+
*
|
|
64
|
+
* Only the keys whose option was provided are emitted, so an absent option
|
|
65
|
+
* produces no attribute rather than an empty-string one — a test asserting on
|
|
66
|
+
* `data-qa-status` must be able to distinguish "no status" from "empty status".
|
|
67
|
+
* `value` is stringified, and `0` and `''` are values like any other: the check
|
|
68
|
+
* is against `undefined`, never against falsiness.
|
|
69
|
+
*/
|
|
70
|
+
export declare const qaAttr: (options: QaAttrOptions) => QaAttributes;
|
|
71
|
+
/**
|
|
72
|
+
* Join an area prefix and a local id into one contract-shaped id.
|
|
73
|
+
*
|
|
74
|
+
* The naming rule is that an id carries the feature area it belongs to, so two
|
|
75
|
+
* features never collide on a bare `"submit"`. An empty or absent prefix
|
|
76
|
+
* returns the local id unchanged, which is what makes an unscoped call site and
|
|
77
|
+
* a scoped one produce the same shape.
|
|
78
|
+
*/
|
|
79
|
+
export declare const joinQaId: (prefix: string | undefined, id: string) => string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read side of the `data-qa-*` contract: turning the attributes a component
|
|
3
|
+
* emits into the selector a test or a browser-driving agent looks them up by.
|
|
4
|
+
*
|
|
5
|
+
* It exists so the two sides cannot drift. A test that hand-writes
|
|
6
|
+
* `[data-qa-id="login-submit"]` has copied an attribute name that only
|
|
7
|
+
* `qaAttr` should know; when the contract gains or renames a family, the
|
|
8
|
+
* hand-written selector keeps compiling and silently stops matching.
|
|
9
|
+
*/
|
|
10
|
+
import { type QaAttrOptions } from './qaAttr.js';
|
|
11
|
+
/**
|
|
12
|
+
* Build a CSS attribute selector matching the elements {@link qaAttr} would
|
|
13
|
+
* emit for the same options.
|
|
14
|
+
*
|
|
15
|
+
* Every provided option narrows the selector, in the contract's own order, so
|
|
16
|
+
* `{ id: 'upload', status: 'ready' }` yields
|
|
17
|
+
* `[data-qa-id="upload"][data-qa-status="ready"]` — the "wait until this
|
|
18
|
+
* element reaches this state" query, expressed once.
|
|
19
|
+
*/
|
|
20
|
+
export declare const qaSelector: (options: QaAttrOptions) => string;
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
|
|
5
|
+
// src/core/qaAttr.ts
|
|
6
|
+
var QA_ATTRIBUTE = {
|
|
7
|
+
id: "data-qa-id",
|
|
8
|
+
value: "data-qa-value",
|
|
9
|
+
status: "data-qa-status"
|
|
10
|
+
};
|
|
11
|
+
var qaAttr = (options) => {
|
|
12
|
+
const attributes = { [QA_ATTRIBUTE.id]: options.id };
|
|
13
|
+
if (options.value !== void 0) {
|
|
14
|
+
attributes[QA_ATTRIBUTE.value] = String(options.value);
|
|
15
|
+
}
|
|
16
|
+
if (options.status !== void 0) {
|
|
17
|
+
attributes[QA_ATTRIBUTE.status] = options.status;
|
|
18
|
+
}
|
|
19
|
+
return attributes;
|
|
20
|
+
};
|
|
21
|
+
var joinQaId = (prefix, id) => prefix === void 0 || prefix === "" ? id : `${prefix}-${id}`;
|
|
22
|
+
|
|
23
|
+
// src/core/qaSelector.ts
|
|
24
|
+
var escapeAttributeValue = (value) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
25
|
+
var qaSelector = (options) => {
|
|
26
|
+
let selector = `[${QA_ATTRIBUTE.id}="${escapeAttributeValue(options.id)}"]`;
|
|
27
|
+
if (options.value !== void 0) {
|
|
28
|
+
selector += `[${QA_ATTRIBUTE.value}="${escapeAttributeValue(String(options.value))}"]`;
|
|
29
|
+
}
|
|
30
|
+
if (options.status !== void 0) {
|
|
31
|
+
selector += `[${QA_ATTRIBUTE.status}="${escapeAttributeValue(options.status)}"]`;
|
|
32
|
+
}
|
|
33
|
+
return selector;
|
|
34
|
+
};
|
|
35
|
+
var QaScopeContext = react.createContext(void 0);
|
|
36
|
+
var QaScope = ({ name, children }) => {
|
|
37
|
+
const parent = react.useContext(QaScopeContext);
|
|
38
|
+
const value = react.useMemo(() => joinQaId(parent, name), [parent, name]);
|
|
39
|
+
return react.createElement(QaScopeContext.Provider, { value }, children);
|
|
40
|
+
};
|
|
41
|
+
var useQaScope = () => react.useContext(QaScopeContext);
|
|
42
|
+
var useQaAttr = (options) => {
|
|
43
|
+
const prefix = useQaScope();
|
|
44
|
+
const { id, value, status } = options;
|
|
45
|
+
return react.useMemo(() => qaAttr({ id: joinQaId(prefix, id), value, status }), [prefix, id, value, status]);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
exports.QA_ATTRIBUTE = QA_ATTRIBUTE;
|
|
49
|
+
exports.QaScope = QaScope;
|
|
50
|
+
exports.joinQaId = joinQaId;
|
|
51
|
+
exports.qaAttr = qaAttr;
|
|
52
|
+
exports.qaSelector = qaSelector;
|
|
53
|
+
exports.useQaAttr = useQaAttr;
|
|
54
|
+
exports.useQaScope = useQaScope;
|
|
55
|
+
//# sourceMappingURL=index.cjs.map
|
|
56
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/qaAttr.ts","../src/core/qaSelector.ts","../src/react/QaScope.tsx"],"names":["createContext","useContext","useMemo","createElement"],"mappings":";;;;;AA6CO,IAAM,YAAA,GAAe;AAAA,EAC1B,EAAA,EAAI,YAAA;AAAA,EACJ,KAAA,EAAO,eAAA;AAAA,EACP,MAAA,EAAQ;AACV;AAwBO,IAAM,MAAA,GAAS,CAAC,OAAA,KAAyC;AAC9D,EAAA,MAAM,aAA2B,EAAE,CAAC,aAAa,EAAE,GAAG,QAAQ,EAAA,EAAG;AAEjE,EAAA,IAAI,OAAA,CAAQ,UAAU,MAAA,EAAW;AAC/B,IAAA,UAAA,CAAW,YAAA,CAAa,KAAK,CAAA,GAAI,MAAA,CAAO,QAAQ,KAAK,CAAA;AAAA,EACvD;AAEA,EAAA,IAAI,OAAA,CAAQ,WAAW,MAAA,EAAW;AAChC,IAAA,UAAA,CAAW,YAAA,CAAa,MAAM,CAAA,GAAI,OAAA,CAAQ,MAAA;AAAA,EAC5C;AAEA,EAAA,OAAO,UAAA;AACT;AAUO,IAAM,QAAA,GAAW,CAAC,MAAA,EAA4B,EAAA,KACnD,MAAA,KAAW,MAAA,IAAa,MAAA,KAAW,EAAA,GAAK,EAAA,GAAK,CAAA,EAAG,MAAM,CAAA,CAAA,EAAI,EAAE,CAAA;;;ACnF9D,IAAM,oBAAA,GAAuB,CAAC,KAAA,KAA0B,KAAA,CAAM,OAAA,CAAQ,OAAO,MAAM,CAAA,CAAE,OAAA,CAAQ,IAAA,EAAM,KAAK,CAAA;AAWjG,IAAM,UAAA,GAAa,CAAC,OAAA,KAAmC;AAC5D,EAAA,IAAI,QAAA,GAAW,IAAI,YAAA,CAAa,EAAE,KAAK,oBAAA,CAAqB,OAAA,CAAQ,EAAE,CAAC,CAAA,EAAA,CAAA;AAEvE,EAAA,IAAI,OAAA,CAAQ,UAAU,MAAA,EAAW;AAC/B,IAAA,QAAA,IAAY,CAAA,CAAA,EAAI,aAAa,KAAK,CAAA,EAAA,EAAK,qBAAqB,MAAA,CAAO,OAAA,CAAQ,KAAK,CAAC,CAAC,CAAA,EAAA,CAAA;AAAA,EACpF;AAEA,EAAA,IAAI,OAAA,CAAQ,WAAW,MAAA,EAAW;AAChC,IAAA,QAAA,IAAY,IAAI,YAAA,CAAa,MAAM,KAAK,oBAAA,CAAqB,OAAA,CAAQ,MAAM,CAAC,CAAA,EAAA,CAAA;AAAA,EAC9E;AAEA,EAAA,OAAO,QAAA;AACT;ACdA,IAAM,cAAA,GAAiBA,oBAAkC,MAAS,CAAA;AAkB3D,IAAM,OAAA,GAAU,CAAC,EAAE,IAAA,EAAM,UAAS,KAAoB;AAC3D,EAAA,MAAM,MAAA,GAASC,iBAAW,cAAc,CAAA;AACxC,EAAA,MAAM,KAAA,GAAQC,aAAA,CAAQ,MAAM,QAAA,CAAS,MAAA,EAAQ,IAAI,CAAA,EAAG,CAAC,MAAA,EAAQ,IAAI,CAAC,CAAA;AAElE,EAAA,OAAOC,oBAAc,cAAA,CAAe,QAAA,EAAU,EAAE,KAAA,IAAS,QAAQ,CAAA;AACnE;AAQO,IAAM,UAAA,GAAa,MAA0BF,gBAAA,CAAW,cAAc;AAStE,IAAM,SAAA,GAAY,CAAC,OAAA,KAAyC;AACjE,EAAA,MAAM,SAAS,UAAA,EAAW;AAC1B,EAAA,MAAM,EAAE,EAAA,EAAI,KAAA,EAAO,MAAA,EAAO,GAAI,OAAA;AAE9B,EAAA,OAAOC,cAAQ,MAAM,MAAA,CAAO,EAAE,EAAA,EAAI,QAAA,CAAS,QAAQ,EAAE,CAAA,EAAG,KAAA,EAAO,MAAA,EAAQ,CAAA,EAAG,CAAC,QAAQ,EAAA,EAAI,KAAA,EAAO,MAAM,CAAC,CAAA;AACvG","file":"index.cjs","sourcesContent":["/**\n * The `data-qa-*` attribute contract, and the helper that emits it.\n *\n * This is a test affordance, not a styling or behaviour hook: a single,\n * documented way to tag DOM elements with deterministic attributes so a\n * browser-driving agent or an end-to-end test can locate elements, read their\n * state, and assert on values without relying on brittle CSS selectors or on\n * matching user-visible text. A test plan written in terms of these ids is a\n * contract between whoever writes the plan and whoever writes the component.\n *\n * ## The three attribute families (all prefixed `data-qa-`)\n *\n * - `data-qa-id=\"<stable-element-id>\"` — a stable, human-readable identifier\n * for an element a test needs to find or click (`\"login-submit\"`,\n * `\"message-input\"`). Keep it unique within a page; for list rows, suffix the\n * entity id (`\"message-<messageId>\"`).\n * - `data-qa-value=\"<value>\"` — the asserted value of an element when the\n * visible text is not a reliable assertion target (a formatted balance, a\n * localized label). Use it when the test must read state, not just presence.\n * - `data-qa-status=\"<state>\"` — a coarse lifecycle token for stateful widgets\n * (`\"loading\" | \"ready\" | \"error\"`), so a test can wait for a known token\n * rather than guessing on a spinner.\n *\n * ## Rules\n *\n * - The attributes are **additive and inert**: they never carry business logic,\n * never affect rendering, and are safe to ship to production. They are\n * deliberately *not* stripped behind an environment flag — an attribute that\n * exists only in a test build cannot be relied on by a test that drives the\n * real one.\n * - Only these three families. A new need extends the contract by a documented\n * change here, never by a one-off attribute name invented at a call site.\n * - The values are test tokens, not user-visible copy. They are intentionally\n * hardcoded identifier strings and are exempt from localization by design.\n *\n * ## Usage\n *\n * ```tsx\n * <button {...qaAttr({ id: 'login-submit' })}>Continue</button>\n * <span {...qaAttr({ id: 'balance', value: balance })}>{formatted}</span>\n * <div {...qaAttr({ id: 'upload', status: 'ready' })} />\n * ```\n */\n\n/** The three attribute names this contract defines, spelled once. */\nexport const QA_ATTRIBUTE = {\n id: 'data-qa-id',\n value: 'data-qa-value',\n status: 'data-qa-status',\n} as const;\n\n/** What {@link qaAttr} accepts. Only `id` is required; the rest are emitted when present. */\nexport interface QaAttrOptions {\n /** Stable, human-readable element identifier (`data-qa-id`). */\n id: string;\n /** Asserted value when visible text is not a reliable target (`data-qa-value`). */\n value?: string | number;\n /** Coarse lifecycle token, e.g. `loading` | `ready` | `error` (`data-qa-status`). */\n status?: string;\n}\n\n/** The emitted attribute bag: attribute name to string value, ready to spread onto an element. */\nexport type QaAttributes = Record<string, string>;\n\n/**\n * Build the `data-qa-*` attribute bag to spread onto an element.\n *\n * Only the keys whose option was provided are emitted, so an absent option\n * produces no attribute rather than an empty-string one — a test asserting on\n * `data-qa-status` must be able to distinguish \"no status\" from \"empty status\".\n * `value` is stringified, and `0` and `''` are values like any other: the check\n * is against `undefined`, never against falsiness.\n */\nexport const qaAttr = (options: QaAttrOptions): QaAttributes => {\n const attributes: QaAttributes = { [QA_ATTRIBUTE.id]: options.id };\n\n if (options.value !== undefined) {\n attributes[QA_ATTRIBUTE.value] = String(options.value);\n }\n\n if (options.status !== undefined) {\n attributes[QA_ATTRIBUTE.status] = options.status;\n }\n\n return attributes;\n};\n\n/**\n * Join an area prefix and a local id into one contract-shaped id.\n *\n * The naming rule is that an id carries the feature area it belongs to, so two\n * features never collide on a bare `\"submit\"`. An empty or absent prefix\n * returns the local id unchanged, which is what makes an unscoped call site and\n * a scoped one produce the same shape.\n */\nexport const joinQaId = (prefix: string | undefined, id: string): string =>\n prefix === undefined || prefix === '' ? id : `${prefix}-${id}`;\n","/**\n * The read side of the `data-qa-*` contract: turning the attributes a component\n * emits into the selector a test or a browser-driving agent looks them up by.\n *\n * It exists so the two sides cannot drift. A test that hand-writes\n * `[data-qa-id=\"login-submit\"]` has copied an attribute name that only\n * `qaAttr` should know; when the contract gains or renames a family, the\n * hand-written selector keeps compiling and silently stops matching.\n */\n\nimport { QA_ATTRIBUTE, type QaAttrOptions } from './qaAttr.js';\n\n/** Escape a value for use inside a CSS attribute selector's quoted string. */\nconst escapeAttributeValue = (value: string): string => value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"');\n\n/**\n * Build a CSS attribute selector matching the elements {@link qaAttr} would\n * emit for the same options.\n *\n * Every provided option narrows the selector, in the contract's own order, so\n * `{ id: 'upload', status: 'ready' }` yields\n * `[data-qa-id=\"upload\"][data-qa-status=\"ready\"]` — the \"wait until this\n * element reaches this state\" query, expressed once.\n */\nexport const qaSelector = (options: QaAttrOptions): string => {\n let selector = `[${QA_ATTRIBUTE.id}=\"${escapeAttributeValue(options.id)}\"]`;\n\n if (options.value !== undefined) {\n selector += `[${QA_ATTRIBUTE.value}=\"${escapeAttributeValue(String(options.value))}\"]`;\n }\n\n if (options.status !== undefined) {\n selector += `[${QA_ATTRIBUTE.status}=\"${escapeAttributeValue(options.status)}\"]`;\n }\n\n return selector;\n};\n","/**\n * The React binding: an area prefix that travels down the tree, so a call site\n * names only the element it is tagging.\n *\n * The contract's naming rule — every id carries the feature area it belongs to,\n * so two features never collide on a bare `\"submit\"` — is a rule a call site\n * has to remember on its own the moment the prefix is typed by hand. A scope\n * makes the prefix structural instead: a feature declares its area once, and\n * every `useQaAttr` beneath it emits `\"<area>-<id>\"` whether or not whoever\n * wrote that line was thinking about the convention.\n *\n * Scopes nest, and a nested scope extends rather than replaces its parent's\n * prefix: a `list` scope inside a `wallet` scope tags `\"wallet-list-row-3\"`.\n * That is what lets a component that is reused in two places carry its own\n * scope without deciding where it will be mounted.\n */\n\nimport { createContext, createElement, useContext, useMemo, type ReactNode } from 'react';\n\nimport { joinQaId, qaAttr, type QaAttributes, type QaAttrOptions } from '../core/qaAttr.js';\n\n/** The prefix in effect for the subtree, or `undefined` at the root where there is none. */\nconst QaScopeContext = createContext<string | undefined>(undefined);\n\nexport interface QaScopeProps {\n /**\n * The area name contributed by this scope, joined to any enclosing one.\n * An empty name contributes nothing, which is what makes a conditionally\n * scoped component legal without a second code path.\n */\n name: string;\n children: ReactNode;\n}\n\n/**\n * Declare the area every `data-qa-id` beneath this point belongs to.\n *\n * It renders no element of its own — it is a context provider and nothing else,\n * so dropping one into a layout can never change the DOM a test is driving.\n */\nexport const QaScope = ({ name, children }: QaScopeProps) => {\n const parent = useContext(QaScopeContext);\n const value = useMemo(() => joinQaId(parent, name), [parent, name]);\n\n return createElement(QaScopeContext.Provider, { value }, children);\n};\n\n/**\n * The prefix in effect at this point in the tree, or `undefined` outside every\n * scope. Exposed for the case a component needs the id string itself — a test\n * hook that reports which element it will drive, for instance — rather than the\n * attribute bag.\n */\nexport const useQaScope = (): string | undefined => useContext(QaScopeContext);\n\n/**\n * Build the `data-qa-*` attribute bag for an element, with the enclosing scope's\n * area already applied to its id.\n *\n * Outside any scope it is exactly `qaAttr`, so a component is free to use it\n * before anyone has decided whether the feature around it gets a scope.\n */\nexport const useQaAttr = (options: QaAttrOptions): QaAttributes => {\n const prefix = useQaScope();\n const { id, value, status } = options;\n\n return useMemo(() => qaAttr({ id: joinQaId(prefix, id), value, status }), [prefix, id, value, status]);\n};\n"]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@firu-daniel/agent-qa-attrs` — the `data-qa-*` contract that makes an
|
|
3
|
+
* application deterministically drivable by browser-driving agents and
|
|
4
|
+
* end-to-end tests.
|
|
5
|
+
*
|
|
6
|
+
* This entry point carries both halves. Consumers with no React dependency —
|
|
7
|
+
* a test runner, a Node script — import `@firu-daniel/agent-qa-attrs/core`
|
|
8
|
+
* instead, which pulls none of it.
|
|
9
|
+
*/
|
|
10
|
+
export * from './core/index.js';
|
|
11
|
+
export * from './react/index.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { joinQaId, qaAttr } from './chunk-GBT5D3KO.js';
|
|
2
|
+
export { QA_ATTRIBUTE, joinQaId, qaAttr, qaSelector } from './chunk-GBT5D3KO.js';
|
|
3
|
+
import { createContext, useContext, useMemo, createElement } from 'react';
|
|
4
|
+
|
|
5
|
+
var QaScopeContext = createContext(void 0);
|
|
6
|
+
var QaScope = ({ name, children }) => {
|
|
7
|
+
const parent = useContext(QaScopeContext);
|
|
8
|
+
const value = useMemo(() => joinQaId(parent, name), [parent, name]);
|
|
9
|
+
return createElement(QaScopeContext.Provider, { value }, children);
|
|
10
|
+
};
|
|
11
|
+
var useQaScope = () => useContext(QaScopeContext);
|
|
12
|
+
var useQaAttr = (options) => {
|
|
13
|
+
const prefix = useQaScope();
|
|
14
|
+
const { id, value, status } = options;
|
|
15
|
+
return useMemo(() => qaAttr({ id: joinQaId(prefix, id), value, status }), [prefix, id, value, status]);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export { QaScope, useQaAttr, useQaScope };
|
|
19
|
+
//# sourceMappingURL=index.js.map
|
|
20
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/react/QaScope.tsx"],"names":[],"mappings":";;;;AAsBA,IAAM,cAAA,GAAiB,cAAkC,MAAS,CAAA;AAkB3D,IAAM,OAAA,GAAU,CAAC,EAAE,IAAA,EAAM,UAAS,KAAoB;AAC3D,EAAA,MAAM,MAAA,GAAS,WAAW,cAAc,CAAA;AACxC,EAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,MAAM,QAAA,CAAS,MAAA,EAAQ,IAAI,CAAA,EAAG,CAAC,MAAA,EAAQ,IAAI,CAAC,CAAA;AAElE,EAAA,OAAO,cAAc,cAAA,CAAe,QAAA,EAAU,EAAE,KAAA,IAAS,QAAQ,CAAA;AACnE;AAQO,IAAM,UAAA,GAAa,MAA0B,UAAA,CAAW,cAAc;AAStE,IAAM,SAAA,GAAY,CAAC,OAAA,KAAyC;AACjE,EAAA,MAAM,SAAS,UAAA,EAAW;AAC1B,EAAA,MAAM,EAAE,EAAA,EAAI,KAAA,EAAO,MAAA,EAAO,GAAI,OAAA;AAE9B,EAAA,OAAO,QAAQ,MAAM,MAAA,CAAO,EAAE,EAAA,EAAI,QAAA,CAAS,QAAQ,EAAE,CAAA,EAAG,KAAA,EAAO,MAAA,EAAQ,CAAA,EAAG,CAAC,QAAQ,EAAA,EAAI,KAAA,EAAO,MAAM,CAAC,CAAA;AACvG","file":"index.js","sourcesContent":["/**\n * The React binding: an area prefix that travels down the tree, so a call site\n * names only the element it is tagging.\n *\n * The contract's naming rule — every id carries the feature area it belongs to,\n * so two features never collide on a bare `\"submit\"` — is a rule a call site\n * has to remember on its own the moment the prefix is typed by hand. A scope\n * makes the prefix structural instead: a feature declares its area once, and\n * every `useQaAttr` beneath it emits `\"<area>-<id>\"` whether or not whoever\n * wrote that line was thinking about the convention.\n *\n * Scopes nest, and a nested scope extends rather than replaces its parent's\n * prefix: a `list` scope inside a `wallet` scope tags `\"wallet-list-row-3\"`.\n * That is what lets a component that is reused in two places carry its own\n * scope without deciding where it will be mounted.\n */\n\nimport { createContext, createElement, useContext, useMemo, type ReactNode } from 'react';\n\nimport { joinQaId, qaAttr, type QaAttributes, type QaAttrOptions } from '../core/qaAttr.js';\n\n/** The prefix in effect for the subtree, or `undefined` at the root where there is none. */\nconst QaScopeContext = createContext<string | undefined>(undefined);\n\nexport interface QaScopeProps {\n /**\n * The area name contributed by this scope, joined to any enclosing one.\n * An empty name contributes nothing, which is what makes a conditionally\n * scoped component legal without a second code path.\n */\n name: string;\n children: ReactNode;\n}\n\n/**\n * Declare the area every `data-qa-id` beneath this point belongs to.\n *\n * It renders no element of its own — it is a context provider and nothing else,\n * so dropping one into a layout can never change the DOM a test is driving.\n */\nexport const QaScope = ({ name, children }: QaScopeProps) => {\n const parent = useContext(QaScopeContext);\n const value = useMemo(() => joinQaId(parent, name), [parent, name]);\n\n return createElement(QaScopeContext.Provider, { value }, children);\n};\n\n/**\n * The prefix in effect at this point in the tree, or `undefined` outside every\n * scope. Exposed for the case a component needs the id string itself — a test\n * hook that reports which element it will drive, for instance — rather than the\n * attribute bag.\n */\nexport const useQaScope = (): string | undefined => useContext(QaScopeContext);\n\n/**\n * Build the `data-qa-*` attribute bag for an element, with the enclosing scope's\n * area already applied to its id.\n *\n * Outside any scope it is exactly `qaAttr`, so a component is free to use it\n * before anyone has decided whether the feature around it gets a scope.\n */\nexport const useQaAttr = (options: QaAttrOptions): QaAttributes => {\n const prefix = useQaScope();\n const { id, value, status } = options;\n\n return useMemo(() => qaAttr({ id: joinQaId(prefix, id), value, status }), [prefix, id, value, status]);\n};\n"]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The React binding: an area prefix that travels down the tree, so a call site
|
|
3
|
+
* names only the element it is tagging.
|
|
4
|
+
*
|
|
5
|
+
* The contract's naming rule — every id carries the feature area it belongs to,
|
|
6
|
+
* so two features never collide on a bare `"submit"` — is a rule a call site
|
|
7
|
+
* has to remember on its own the moment the prefix is typed by hand. A scope
|
|
8
|
+
* makes the prefix structural instead: a feature declares its area once, and
|
|
9
|
+
* every `useQaAttr` beneath it emits `"<area>-<id>"` whether or not whoever
|
|
10
|
+
* wrote that line was thinking about the convention.
|
|
11
|
+
*
|
|
12
|
+
* Scopes nest, and a nested scope extends rather than replaces its parent's
|
|
13
|
+
* prefix: a `list` scope inside a `wallet` scope tags `"wallet-list-row-3"`.
|
|
14
|
+
* That is what lets a component that is reused in two places carry its own
|
|
15
|
+
* scope without deciding where it will be mounted.
|
|
16
|
+
*/
|
|
17
|
+
import { type ReactNode } from 'react';
|
|
18
|
+
import { type QaAttributes, type QaAttrOptions } from '../core/qaAttr.js';
|
|
19
|
+
export interface QaScopeProps {
|
|
20
|
+
/**
|
|
21
|
+
* The area name contributed by this scope, joined to any enclosing one.
|
|
22
|
+
* An empty name contributes nothing, which is what makes a conditionally
|
|
23
|
+
* scoped component legal without a second code path.
|
|
24
|
+
*/
|
|
25
|
+
name: string;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Declare the area every `data-qa-id` beneath this point belongs to.
|
|
30
|
+
*
|
|
31
|
+
* It renders no element of its own — it is a context provider and nothing else,
|
|
32
|
+
* so dropping one into a layout can never change the DOM a test is driving.
|
|
33
|
+
*/
|
|
34
|
+
export declare const QaScope: ({ name, children }: QaScopeProps) => import("react").FunctionComponentElement<import("react").ProviderProps<string | undefined>>;
|
|
35
|
+
/**
|
|
36
|
+
* The prefix in effect at this point in the tree, or `undefined` outside every
|
|
37
|
+
* scope. Exposed for the case a component needs the id string itself — a test
|
|
38
|
+
* hook that reports which element it will drive, for instance — rather than the
|
|
39
|
+
* attribute bag.
|
|
40
|
+
*/
|
|
41
|
+
export declare const useQaScope: () => string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Build the `data-qa-*` attribute bag for an element, with the enclosing scope's
|
|
44
|
+
* area already applied to its id.
|
|
45
|
+
*
|
|
46
|
+
* Outside any scope it is exactly `qaAttr`, so a component is free to use it
|
|
47
|
+
* before anyone has decided whether the feature around it gets a scope.
|
|
48
|
+
*/
|
|
49
|
+
export declare const useQaAttr: (options: QaAttrOptions) => QaAttributes;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The React half of the contract. It binds `src/core`'s attribute helper to a
|
|
3
|
+
* scope that travels down the tree; it adds no attribute family of its own.
|
|
4
|
+
*/
|
|
5
|
+
export { QaScope, useQaScope, useQaAttr } from './QaScope.js';
|
|
6
|
+
export type { QaScopeProps } from './QaScope.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@firu-daniel/agent-qa-attrs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A data-qa-* attribute contract that makes a React app deterministically drivable by browser-driving agents and end-to-end tests.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "firu-daniel",
|
|
7
|
+
"homepage": "https://github.com/firu-daniel/straightedge/tree/main/packages/agent-qa-attrs#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/firu-daniel/straightedge.git",
|
|
11
|
+
"directory": "packages/agent-qa-attrs"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"testing",
|
|
15
|
+
"e2e",
|
|
16
|
+
"playwright",
|
|
17
|
+
"data-qa",
|
|
18
|
+
"react",
|
|
19
|
+
"agents",
|
|
20
|
+
"test-attributes"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"main": "./dist/index.cjs",
|
|
25
|
+
"module": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"require": "./dist/index.cjs"
|
|
32
|
+
},
|
|
33
|
+
"./core": {
|
|
34
|
+
"types": "./dist/core/index.d.ts",
|
|
35
|
+
"import": "./dist/core/index.js",
|
|
36
|
+
"require": "./dist/core/index.cjs"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public",
|
|
46
|
+
"provenance": true
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup && npm run build:types",
|
|
50
|
+
"clean": "rm -rf dist",
|
|
51
|
+
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly --declaration --outDir dist"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"react": ">=18"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"react": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|