Skip to content

Commit 0bdbc8f

Browse files
aramos-adobeclaude
andcommitted
feat(plop): add test and vrt retrofit generators
Add standalone `test` and `vrt` plop generators that retrofit an existing 2nd-gen component with unit + a11y test files and a Chromatic VRT story. Component-side actions are unchanged from the base scaffolder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 929f58b commit 0bdbc8f

5 files changed

Lines changed: 417 additions & 83 deletions

File tree

2nd-gen/scaffolding/README.md

Lines changed: 93 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,117 +1,144 @@
11
# 2nd-gen scaffolding
22

3-
A [plop](https://plopjs.com/) generator that produces the deterministic file
4-
skeleton for a 2nd-gen component: the `core` base layer, the `swc` concrete
5-
layer, and the stories, docs, and test files that go with them.
3+
A [plop](https://plopjs.com/) scaffolder that produces the deterministic file
4+
skeletons for 2nd-gen components: the component itself, and the **test** and
5+
**VRT** files that go with it.
66

7-
The generator exists to take the mechanical, every-time-identical part of
8-
Phase 2 (`migration-setup`) off the critical path. It writes the boilerplate so
9-
a human or an agent only has to apply the decisions that actually differ between
10-
components: the base-vs-concrete property split, the real Spectrum 2 tokens, the
11-
variant and state API, and the prose in the docs page.
7+
The generators exist to take the mechanical, every-time-identical part of
8+
authoring off the critical path. They write the boilerplate so a human or an
9+
agent only has to apply the decisions that actually differ between components:
10+
the base-vs-concrete property split, the real Spectrum 2 tokens, the
11+
variant/state API, the assertions, and the docs prose.
12+
13+
## Generators
14+
15+
| Generator | Scaffolds | Target |
16+
| ----------- | ------------------------------------------------------- | --------------------------------- |
17+
| `component` | Core base + SWC concrete + stories, docs, tests | new component (`core` + `swc`) |
18+
| `test` | Unit (`*.test.ts`) + a11y (`*.a11y.spec.ts`) test files | **existing** component (retrofit) |
19+
| `vrt` | A Chromatic VRT story (`test/vrt/<name>.vrt.ts`) | **existing** component (retrofit) |
20+
21+
`component` always emits a baseline `test` + `a11y` file. Use the standalone
22+
`test` and `vrt` generators to add coverage to a component that already exists
23+
(for example, one migrated before these generators landed) — `vrt` in particular
24+
fills a gap, since the `component` skeleton ships no `.vrt.ts`.
1225

1326
## Usage
1427

1528
### Interactive (humans)
1629

1730
```bash
1831
yarn plop component
32+
yarn plop test
33+
yarn plop vrt
1934
```
2035

21-
You will be prompted for a component name. Enter it in any form: `action-button`,
22-
`actionButton`, `Action Button`, or even `sp-action-button` (the `sp-`/`swc-`
23-
prefix is stripped). The name is normalized by the built-in case helpers, so the
24-
output is identical regardless of how you type it.
36+
You are prompted for a name (enter it in any form: `action-button`,
37+
`actionButton`, `Action Button`, or `sp-action-button` — the `sp-`/`swc-`
38+
prefix is stripped and the case helpers normalize the rest).
2539

2640
### Headless (agents, scripts, CI)
2741

28-
Pass the name as a bypass argument and `--force` to skip the interactive
29-
confirmation:
42+
Pass the name as a positional bypass argument, plus `--force`:
3043

3144
```bash
3245
yarn plop component "action-button" --force
46+
yarn plop test "action-button" --force
47+
yarn plop vrt "action-button" --force
3348
```
3449

35-
## What it generates
50+
Each generator emits a single fixed scaffold (a permutation-grid + forced-colors
51+
story for `vrt`, and a play-function + a11y-snapshot pair for `test`). The output
52+
is lint-clean and formatted; `TODO`s mark where the author fills in the
53+
component-specific detail.
3654

37-
For a component named `action-button`:
55+
## Retrofit contract (`test`, `vrt`)
3856

39-
```
40-
2nd-gen/packages/core/components/action-button/
41-
ActionButton.types.ts VALID_SIZES + Size type
42-
ActionButton.base.ts abstract base class (SizedMixin + SpectrumElement)
43-
index.ts re-exports base + types
44-
45-
2nd-gen/packages/swc/components/action-button/
46-
ActionButton.ts concrete class, render(), styles getter
47-
index.ts re-exports the concrete class
48-
swc-action-button.ts defineElement registration + tag-name map
49-
action-button.css :host + .swc-ActionButton block, token() scaffolding
50-
action-button.mdx per-unit docs page (DocsHeader/Canvas/DocsFooter)
51-
stories/action-button.stories.ts Playground/Overview/Anatomy/Sizes/States/Accessibility
52-
test/action-button.test.ts Vitest play function reusing the Overview story
53-
test/action-button.a11y.spec.ts Playwright ARIA-snapshot accessibility test
54-
```
57+
`test` and `vrt` add files to a component that **already exists**. They:
58+
59+
- **Guard first.** If `2nd-gen/packages/swc/components/<name>/` is missing, the
60+
generator aborts with a clear message before writing anything (a typo cannot
61+
silently scaffold tests for a non-existent component).
62+
- **Never clobber by default.** The `add` actions use `skipIfExists`, so an
63+
existing test/VRT file is left untouched. Pass `--force` to overwrite, or
64+
delete the file first to regenerate it.
65+
- **Stay scoped.** They format only the `test/` (or `test/vrt/`) subtree they
66+
wrote, leaving the surrounding component files untouched.
5567

56-
It also wires the core package's `exports` map: the `swc` package uses wildcard
57-
exports and needs no edit, but `@spectrum-web-components/core` uses explicit
58-
per-component entries, so the generator adds `./components/action-button` and
59-
`./components/action-button/index.js` and re-sorts the `exports` keys
60-
alphabetically (a minimal, deterministic diff). Finally it runs Prettier on the
61-
two new directories so the output lands pre-formatted.
68+
## What gets wired automatically
6269

63-
## What it intentionally does NOT do
70+
- **Core `exports` + `typesVersions`.** The SWC package (`@adobe/spectrum-wc`)
71+
uses wildcard `exports`, so a new component needs no package.json edit there.
72+
The core package (`@spectrum-web-components/core`) uses explicit per-component
73+
entries, so the `component` generator adds both the `exports` and the matching
74+
`typesVersions` entry for the new component and re-sorts the keys (a minimal,
75+
deterministic diff). The `test`/`vrt` generators touch no package.json.
76+
- **Formatting.** Each generator runs Prettier on the directories it wrote.
6477

65-
The skeleton is a starting point, not a finished component. After generating,
66-
follow `migration-setup` and the later migration phases to:
78+
## Custom-element registration
6779

68-
- Move properties, methods, and types from 1st-gen into the base and concrete
69-
classes (Phase 3, `migration-api`).
70-
- Implement semantics, ARIA, and keyboard support (Phase 4, `migration-a11y`).
71-
- Replace the placeholder CSS with migrated Spectrum 2 tokens (Phase 5,
72-
`migration-styling`).
73-
- Flesh out the tests and stories (Phase 6, `migration-testing`).
74-
- Write the docs prose and any `migration-guide.mdx` (Phase 7,
75-
`migration-documentation`; the consumer guide is owned by the
76-
`consumer-migration-guide` skill, so it is not scaffolded here).
80+
Components are custom elements; the split matters:
7781

78-
Every generated file contains `TODO`/placeholder markers showing where this work
79-
goes.
82+
- `index.ts` re-exports the class only (no registration).
83+
- `swc-<tag>.ts` is the side-effectful entry: it calls
84+
`defineElement('swc-<tag>', Class)` (from `@adobe/spectrum-wc-core/element`)
85+
and augments `HTMLElementTagNameMap`.
86+
87+
## What the generators intentionally do NOT do
88+
89+
The skeletons are starting points, not finished components. After generating,
90+
follow `migration-setup` and the later migration phases (or the `vrt-authoring`
91+
skill for VRT) to move properties/types into the classes, implement semantics and
92+
ARIA, migrate CSS to Spectrum 2 tokens, and flesh out the tests, VRT grids, and
93+
docs prose. Every generated file contains `TODO`/placeholder markers.
8094

8195
## Conventions the templates encode
8296

83-
These mirror the `badge` reference component and the project rules in `.ai/`:
97+
These mirror the `badge` and `button` reference components and the project rules
98+
in `.ai/`:
8499

85100
- **Two-layer architecture.** Shared, non-visual API lives on the core base
86101
class; visual and version-specific API lives on the concrete `swc` class.
87-
- **Sizing via host attribute.** `SizedMixin` reflects `size` to the host, so
88-
size is styled with `:host([size="..."])` selectors, never a modifier class.
89-
- **Public styling API.** Custom properties are exposed as `--swc-<name>-*` with
90-
a `token()` fallback.
102+
- **Sizing via host attribute.** `SizedMixin` reflects `size` to the host, styled
103+
with `:host([size="..."])` selectors, never a modifier class.
104+
- **Public styling API.** Custom properties exposed as `--swc-<name>-*` with a
105+
`token()` fallback.
91106
- **BEM-ish class block.** The render root carries `swc-<PascalCase>`.
92-
- **Stories + per-unit MDX.** The Playground is tagged `['dev']` (not
93-
`['autodocs', 'dev']`) because the generated `.mdx` is the docs page; each
107+
- **Stories + per-unit MDX.** The Playground is tagged `['dev']`; each
94108
section-tagged story is referenced from the MDX via `<Canvas of={...} />`.
109+
- **Sentence-case titles.** Storybook titles are sentence case (`Action button`),
110+
matching the project's title rule.
95111

96112
## Editing the templates
97113

98-
Templates live in `templates/component/` as Handlebars (`.hbs`) files. Name
99-
derivations use plop's built-in case helpers plus two custom helpers defined in
114+
Templates live under `templates/` as Handlebars (`.hbs`) files, grouped by
115+
generator:
116+
117+
```
118+
templates/
119+
component/{core,swc}/*.hbs the component skeleton
120+
test/*.hbs the standalone test + a11y files
121+
vrt/vrt.ts.hbs the VRT story
122+
```
123+
124+
Name derivations use plop's built-in case helpers plus custom helpers defined in
100125
`plopfile.js`:
101126

102127
| Helper | `action-button` renders as |
103128
| ----------------------- | -------------------------- |
104129
| `{{dashCase name}}` | `action-button` |
105130
| `{{pascalCase name}}` | `ActionButton` |
106131
| `{{constantCase name}}` | `ACTION_BUTTON` |
107-
| `{{titleName name}}` | `Action Button` (custom) |
132+
| `{{titleName name}}` | `Action button` (custom) |
108133
| `{{lb}}` / `{{rb}}` | `{` / `}` (custom) |
109134

110-
`titleName` produces the space-separated proper-noun title used for Storybook
135+
`titleName` produces the space-separated sentence-case title used for Storybook
111136
titles; plop's built-in `titleCase` keeps the dash (`Action-Button`) and must not
112137
be used for titles. `lb`/`rb` emit literal braces in `.mdx` templates, where a
113-
bare `{` next to a `{{helper}}` would otherwise be parsed as Handlebars.
138+
`{` next to a `{{helper}}` would otherwise be parsed as Handlebars.
114139

115140
When you change the file layout, naming, or wiring, update both the templates and
116-
the `add` actions in `plopfile.js`, then regenerate a throwaway component and run
117-
`yarn lint` / `yarn lint:css` on it to confirm the output still passes.
141+
the actions in `plopfile.js`, then regenerate a throwaway component and run
142+
`yarn lint`, `yarn lint:css`, and `yarn lint:docs-pages` on it to confirm the
143+
output still passes. Delete the throwaway component and revert
144+
`2nd-gen/packages/core/package.json` afterward.

2nd-gen/scaffolding/plopfile.js

Lines changed: 121 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,26 @@ const CORE_COMPONENTS = '2nd-gen/packages/core/components';
2222
const SWC_COMPONENTS = '2nd-gen/packages/swc/components';
2323

2424
/**
25-
* 2nd-gen component scaffolder.
25+
* 2nd-gen scaffolder.
2626
*
2727
* Mirrors the file layout and conventions described by the migration skills
28-
* (`migration-setup`, `stories-format`, `stories-documentation`) and the `badge`
29-
* reference component. It produces the deterministic Phase 2 skeleton so the
30-
* migration-setup skill only has to apply plan-specific architecture decisions
31-
* (the base-vs-concrete split) rather than author every file by hand.
28+
* (`migration-setup`, `stories-format`, `stories-documentation`, `vrt-authoring`)
29+
* and the `badge` / `button` reference units. It produces the deterministic
30+
* skeleton so a human or an agent only has to apply the decisions that actually
31+
* differ between units (the base-vs-concrete split, the real API, the
32+
* assertions) rather than author every file by hand.
33+
*
34+
* Generators:
35+
* component — a custom-element component (core base + swc concrete + docs/tests)
36+
* test — retrofit an existing component with unit + a11y test files
37+
* vrt — retrofit an existing component with a Chromatic VRT story
3238
*
3339
* Built-in plop case helpers do most name derivation, plus the custom
34-
* `titleName` helper for the space-separated proper-noun title:
40+
* `titleName` helper for the space-separated sentence-case title:
3541
* {{dashCase name}} -> action-button (kebab tag, dir, css, file names)
3642
* {{pascalCase name}} -> ActionButton (class names, CSS BEM block)
3743
* {{constantCase name}} -> ACTION_BUTTON (exported constant prefixes)
38-
* {{titleName name}} -> Action Button (Storybook title, proper noun)
44+
* {{titleName name}} -> Action button (Storybook title, sentence case)
3945
*
4046
* @param {import('plop').NodePlopAPI} plop
4147
*/
@@ -54,19 +60,37 @@ export default function (plop) {
5460
plop.setHelper('lb', () => '{');
5561
plop.setHelper('rb', () => '}');
5662

57-
// Space-separated Title Case for human-facing labels (Storybook titles,
58-
// describe blocks, prose). plop's built-in `titleCase` keeps the dash
59-
// ("action-button" -> "Action-Button"), but Spectrum treats component names
60-
// as proper nouns rendered with spaces ("Action Button").
61-
plop.setHelper('titleName', (text) =>
62-
String(text)
63+
// Space-separated sentence-case label for human-facing text (Storybook
64+
// titles, describe blocks, prose). Spectrum titles are sentence case:
65+
// capitalize only the first word ("action-button" -> "Action button").
66+
// Authors re-capitalize any acronyms or proper nouns the scaffold can't know.
67+
// plop's built-in `titleCase` can't do this (it keeps the dash and caps every
68+
// word).
69+
plop.setHelper('titleName', (text) => {
70+
const words = String(text)
6371
.replace(/^(sp|swc)-/i, '')
6472
.replace(/([a-z0-9])([A-Z])/g, '$1 $2') // split camelCase
6573
.split(/[-_\s]+/)
6674
.filter(Boolean)
67-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
68-
.join(' ')
69-
);
75+
.map((word) => word.toLowerCase());
76+
if (words.length === 0) {
77+
return '';
78+
}
79+
words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1);
80+
return words.join(' ');
81+
});
82+
83+
// Shared prompt for the retrofit generators. A component name is entered in
84+
// any form (`action-button`, `actionButton`, `Action Button`,
85+
// `sp-action-button`); the case helpers normalize it.
86+
const namePrompt = (message) => ({
87+
type: 'input',
88+
name: 'name',
89+
message,
90+
filter: (input) => input.trim().replace(/^(sp|swc)-/i, ''),
91+
validate: (input) =>
92+
input.trim().length > 0 || 'A name is required. You can rename it later.',
93+
});
7094

7195
// ──────────────────────
7296
// CUSTOM ACTIONS
@@ -142,8 +166,42 @@ export default function (plop) {
142166
}
143167
});
144168

169+
// Retrofit generators (`test`, `vrt`) only make sense for a component that
170+
// already exists. Fail loudly and early — before any file is written — when
171+
// the target directory is missing, so a typo does not silently scaffold tests
172+
// for a non-existent component.
173+
plop.setActionType('assert-component-exists', (answers) => {
174+
const name = render('{{dashCase name}}', answers);
175+
const dir = path.join(repoRoot, SWC_COMPONENTS, name);
176+
if (!fs.existsSync(dir)) {
177+
throw new Error(
178+
`Component "${name}" does not exist at ${SWC_COMPONENTS}/${name}. ` +
179+
`Scaffold it first with: yarn plop component "${name}"`
180+
);
181+
}
182+
return `found component ${name}`;
183+
});
184+
185+
// Format the directories a retrofit generator wrote (a `test/` or `test/vrt/`
186+
// subtree), scoped so the surrounding component files are left untouched.
187+
// Config: { paths: string[] } — handlebars-templated repo-relative dirs.
188+
plop.setActionType('format-dir', (answers, config) => {
189+
const targets = (config.paths ?? [])
190+
.map((p) => render(p, answers))
191+
.join(' ');
192+
try {
193+
execSync(`yarn prettier --write ${targets}`, {
194+
cwd: repoRoot,
195+
stdio: 'ignore',
196+
});
197+
return `formatted ${targets}`;
198+
} catch {
199+
return `skipped formatting (run "yarn lint" manually)`;
200+
}
201+
});
202+
145203
// ──────────────────
146-
// GENERATOR
204+
// GENERATORS
147205
// ──────────────────
148206

149207
plop.setGenerator('component', {
@@ -232,4 +290,50 @@ export default function (plop) {
232290
];
233291
},
234292
});
293+
294+
plop.setGenerator('test', {
295+
description: 'Retrofit an existing component with unit + a11y test files',
296+
prompts: [namePrompt('Component to add tests for (e.g. action-button):')],
297+
actions: () => {
298+
const swcDir = `${SWC_COMPONENTS}/{{dashCase name}}`;
299+
300+
return [
301+
{ type: 'assert-component-exists' },
302+
{
303+
type: 'add',
304+
path: `${swcDir}/test/{{dashCase name}}.test.ts`,
305+
templateFile: path.join(here, 'templates/test/test.ts.hbs'),
306+
skipIfExists: true,
307+
},
308+
{
309+
type: 'add',
310+
path: `${swcDir}/test/{{dashCase name}}.a11y.spec.ts`,
311+
templateFile: path.join(here, 'templates/test/a11y.spec.ts.hbs'),
312+
skipIfExists: true,
313+
},
314+
{ type: 'format-dir', paths: [`${swcDir}/test`] },
315+
];
316+
},
317+
});
318+
319+
plop.setGenerator('vrt', {
320+
description: 'Retrofit an existing component with a Chromatic VRT story',
321+
prompts: [
322+
namePrompt('Component to add a VRT story for (e.g. action-button):'),
323+
],
324+
actions: () => {
325+
const swcDir = `${SWC_COMPONENTS}/{{dashCase name}}`;
326+
327+
return [
328+
{ type: 'assert-component-exists' },
329+
{
330+
type: 'add',
331+
path: `${swcDir}/test/vrt/{{dashCase name}}.vrt.ts`,
332+
templateFile: path.join(here, 'templates/vrt/vrt.ts.hbs'),
333+
skipIfExists: true,
334+
},
335+
{ type: 'format-dir', paths: [`${swcDir}/test/vrt`] },
336+
];
337+
},
338+
});
235339
}

0 commit comments

Comments
 (0)