Skip to content

Commit 0ae6681

Browse files
committed
feat: make the e2e feature require counter
Add a one-directional, transitively-resolved `requires` field to FeatureDefinition; `e2e` declares `requires: ['counter']` because the suite drives the counter dapp. Resolution lives in pure helpers in utils.ts: - resolveSelectedFeatures expands a selection with its transitive requirements (non-interactive: --features e2e yields [counter, e2e]) - applyFeatureToggle keeps the interactive multiselect consistent — selecting pulls requirements in, deselecting cascades dependents out --info surfaces each feature's `requires` so agents can resolve dependencies themselves, and --help documents the auto-resolution.
1 parent 0cd8b17 commit 0ae6681

12 files changed

Lines changed: 262 additions & 18 deletions

File tree

architecture.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,14 @@ type FeatureDefinition = {
8585
default: boolean // --info output
8686
postInstall?: string[] // post-install instructions for non-interactive JSON output
8787
paths?: string[] // files/dirs removed when the feature is deselected (Canton, data-driven cleanup)
88+
requires?: FeatureName[] // features this one depends on (one-directional, transitive)
8889
}
8990
```
9091
9192
When adding a new feature, add it to the relevant stack's `features` map. Programmatic consumers pick it up automatically. Canton feature cleanup is fully data-driven from `paths` (see `cleanupFiles.ts` below), so a new Canton feature needs no cleanup code — only its `paths`. EVM features still need an explicit per-feature cleanup function. The CLI `--help` text in `cli.tsx` maintains its own copy in both cases.
9293
94+
**Feature dependencies (`requires`)** are resolved by pure helpers in `utils.ts`. `resolveSelectedFeatures(stack, selected)` expands a selection to include every transitive requirement (used by the non-interactive path, so `--features e2e` yields `[counter, e2e]`). `applyFeatureToggle(stack, selection, toggled, action)` keeps the interactive multiselect consistent: selecting a feature pulls its requirements in, deselecting one cascades its dependents out. `e2e requires counter` is the only dependency today. `--info` surfaces each feature's `requires` so agents can resolve dependencies themselves.
95+
9396
### Operations Layer (`source/operations/`)
9497
9598
Plain async functions, no UI dependencies. Each operation that varies per stack takes `stack: Stack` as its first argument. Multi-step operations accept an optional `onProgress` callback for the TUI; the non-interactive path omits it.

readme.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,14 @@ pnpm dlx dappbooster --canton --ni --name my_canton --mode custom --features cou
138138
| Feature | Key | Default | Description |
139139
|---|---|---|---|
140140
| Counter demo | `counter` || Counter demo dapp (frontend + Daml + wallet-service) |
141-
| E2E tests | `e2e` || Playwright end-to-end test suite |
141+
| E2E tests | `e2e` || Playwright end-to-end test suite (**requires `counter`**) |
142142
| Carpincho wallet | `carpincho` || Carpincho browser-extension wallet (frontend + build tooling) |
143143
| LLM & agent artifacts | `llm` || `.claude`, `AGENTS.md`, `CLAUDE.md`, `architecture.md`, `llms.txt`, … |
144144

145+
`e2e` drives the counter dapp, so it **requires** `counter`: requesting `--features e2e` auto-pulls
146+
`counter` in (the success JSON reports `["counter", "e2e"]`), and in the wizard, deselecting
147+
`counter` also unchecks `e2e`.
148+
145149
The Canton scaffold uses **npm** (a property of the generated project, not this installer). After
146150
install: review `canton-barebones/.env`, run `npm run canton:up` to start the local Canton stack,
147151
and `npm run app:dev` to run the counter dapp frontend. When `carpincho` is included, build the

source/__tests__/info.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,35 @@ describe('getInfoOutput — no filter', () => {
6565
}
6666
})
6767

68-
it('does not leak label or packages into feature output', () => {
68+
it('surfaces requires only for features that declare a dependency', () => {
69+
const output = JSON.parse(getInfoOutput())
70+
71+
for (const stack of stackNames) {
72+
for (const [name, def] of Object.entries(stackDefinitions[stack].features)) {
73+
const feature = output.stacks[stack].features[name]
74+
if (def.requires) {
75+
expect(feature.requires).toEqual(def.requires)
76+
} else {
77+
expect(feature).not.toHaveProperty('requires')
78+
}
79+
}
80+
}
81+
})
82+
83+
it('exposes the e2e -> counter dependency for agents', () => {
84+
const output = JSON.parse(getInfoOutput('canton'))
85+
expect(output.stacks.canton.features.e2e.requires).toEqual(['counter'])
86+
})
87+
88+
it('does not leak label, packages, or paths into feature output', () => {
6989
const output = JSON.parse(getInfoOutput())
7090

7191
for (const stack of stackNames) {
7292
for (const name of Object.keys(stackDefinitions[stack].features)) {
7393
const feature = output.stacks[stack].features[name]
7494
expect(feature).not.toHaveProperty('label')
7595
expect(feature).not.toHaveProperty('packages')
96+
expect(feature).not.toHaveProperty('paths')
7697
}
7798
}
7899
})

source/__tests__/nonInteractive.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,28 @@ describe('nonInteractive — canton execution', () => {
295295
const postInstall = output.postInstall as string[]
296296
expect(postInstall.some((msg) => msg.includes('canton:up'))).toBe(true)
297297
})
298+
299+
it('auto-pulls counter when only e2e is requested (e2e requires counter)', async () => {
300+
await runNonInteractive({
301+
stack: 'canton',
302+
name: 'my_app',
303+
mode: 'custom',
304+
features: 'e2e',
305+
})
306+
307+
expect(installPackages).toHaveBeenCalledWith(
308+
'canton',
309+
expect.stringContaining('my_app'),
310+
'custom',
311+
['counter', 'e2e'],
312+
)
313+
314+
const output = getLastJsonOutput()
315+
expect(output.features).toEqual(['counter', 'e2e'])
316+
// counter's post-install messages come along with the pulled-in feature
317+
const postInstall = output.postInstall as string[]
318+
expect(postInstall.some((msg) => msg.includes('canton:up'))).toBe(true)
319+
})
298320
})
299321

300322
describe('nonInteractive — custom mode execution', () => {

source/__tests__/utils.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { describe, expect, it } from 'vitest'
22
import { stackDefinitions } from '../constants/config.js'
33
import {
4+
applyFeatureToggle,
45
deriveStepDisplay,
56
getPackagesToRemove,
67
getPostInstallMessages,
78
isFeatureSelected,
89
isValidName,
10+
resolveSelectedFeatures,
911
} from '../utils/utils.js'
1012

1113
const evmFeatures = stackDefinitions.evm.features
@@ -136,6 +138,66 @@ describe('getPostInstallMessages', () => {
136138
})
137139
})
138140

141+
describe('resolveSelectedFeatures — canton (e2e requires counter)', () => {
142+
it('pulls counter in when only e2e is selected', () => {
143+
expect(resolveSelectedFeatures('canton', ['e2e'])).toEqual(['counter', 'e2e'])
144+
})
145+
146+
it('leaves an already-complete selection unchanged', () => {
147+
expect(resolveSelectedFeatures('canton', ['counter', 'e2e'])).toEqual(['counter', 'e2e'])
148+
})
149+
150+
it('orders the resolved set by config order, not selection order', () => {
151+
expect(resolveSelectedFeatures('canton', ['e2e', 'carpincho'])).toEqual([
152+
'counter',
153+
'e2e',
154+
'carpincho',
155+
])
156+
})
157+
158+
it('does not pull e2e in when only counter is selected (one-directional)', () => {
159+
expect(resolveSelectedFeatures('canton', ['counter'])).toEqual(['counter'])
160+
})
161+
162+
it('de-duplicates when a requirement is already present', () => {
163+
expect(resolveSelectedFeatures('canton', ['counter', 'e2e', 'carpincho'])).toEqual([
164+
'counter',
165+
'e2e',
166+
'carpincho',
167+
])
168+
})
169+
})
170+
171+
describe('resolveSelectedFeatures — evm (no requires)', () => {
172+
it('returns the selection unchanged, in config order', () => {
173+
expect(resolveSelectedFeatures('evm', ['subgraph', 'demo'])).toEqual(['demo', 'subgraph'])
174+
})
175+
})
176+
177+
describe('applyFeatureToggle — canton (interactive cascade)', () => {
178+
it('selecting e2e pulls counter in', () => {
179+
expect(applyFeatureToggle('canton', ['carpincho'], 'e2e', 'select')).toEqual([
180+
'counter',
181+
'e2e',
182+
'carpincho',
183+
])
184+
})
185+
186+
it('unselecting counter cascades e2e out', () => {
187+
expect(
188+
applyFeatureToggle('canton', ['counter', 'e2e', 'carpincho'], 'counter', 'unselect'),
189+
).toEqual(['carpincho'])
190+
})
191+
192+
it('unselecting e2e leaves counter alone', () => {
193+
expect(applyFeatureToggle('canton', ['counter', 'e2e'], 'e2e', 'unselect')).toEqual(['counter'])
194+
})
195+
196+
it('selecting counter does not pull e2e', () => {
197+
expect(applyFeatureToggle('canton', [], 'counter', 'select')).toEqual(['counter'])
198+
})
199+
})
200+
139201
describe('deriveStepDisplay', () => {
140202
it('shows all steps as completed when done', () => {
141203
const result = deriveStepDisplay(['Step 1', 'Step 2', 'Step 3'], 'done')

source/cli.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ const cli = meow(
2727
husky Git hooks (Husky, lint-staged, commitlint)
2828
Canton:
2929
counter Counter demo dapp
30-
e2e Playwright end-to-end tests
30+
e2e Playwright end-to-end tests (requires counter)
3131
carpincho Carpincho browser-extension wallet
3232
llm LLM and agent artifacts (.claude, AGENTS.md, …)
33+
Dependencies are auto-resolved: requesting e2e
34+
also pulls in counter.
3335
--non-interactive, --ni Run without prompts (auto-enabled when not a TTY)
3436
--info Output feature metadata as JSON (filter with --stack)
3537
--help Show this help
@@ -41,8 +43,11 @@ const cli = meow(
4143
Use --ni to force non-interactive mode in a TTY environment.
4244
4345
AI agents: non-interactive mode activates automatically. Run --info
44-
to discover available stacks and features, then pass --canton or --evm
45-
plus --name and --mode flags. Output is JSON for easy parsing.
46+
to discover available stacks and features (including each feature's
47+
"requires"), then pass --canton or --evm plus --name and --mode flags.
48+
Feature dependencies are resolved automatically, so the returned
49+
"features" list may include extras pulled in by your selection.
50+
Output is JSON for easy parsing.
4651
4752
Examples
4853
Interactive (prompts for stack and options):

source/components/Multiselect/MultiSelect.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ type MultiSelectProps<T> = {
2323
onUnselect?: (unselectedItem: Item<T>) => void
2424
onSubmit?: (selectedItems: Item<T>[]) => void
2525
onHighlight?: (highlightedItem: Item<T>) => void
26+
// Optional hook to post-process a toggle (e.g. enforce feature dependencies). Receives the
27+
// naive post-toggle selection plus the item that was toggled and the action taken.
28+
transformSelection?: (
29+
nextSelected: Item<T>[],
30+
toggledItem: Item<T>,
31+
action: 'select' | 'unselect',
32+
) => Item<T>[]
2633
}
2734

2835
const MultiSelect = <T,>({
@@ -38,6 +45,7 @@ const MultiSelect = <T,>({
3845
onUnselect = () => {},
3946
onSubmit = () => {},
4047
onHighlight = () => {},
48+
transformSelection,
4149
}: MultiSelectProps<T>) => {
4250
const [highlightedIndex, setHighlightedIndex] = useState(initialIndex)
4351
const [selectedItems, setSelectedItems] = useState(defaultSelected)
@@ -56,19 +64,29 @@ const MultiSelect = <T,>({
5664

5765
const handleSelect = useCallback(
5866
(item: Item<T>) => {
59-
if (includesItems(item, selectedItems)) {
60-
const newSelectedItems = selectedItems.filter(
61-
(selectedItem) => selectedItem.value !== item.value && selectedItem.label !== item.label,
62-
)
63-
setSelectedItems(newSelectedItems)
67+
const isCurrentlySelected = includesItems(item, selectedItems)
68+
const action = isCurrentlySelected ? 'unselect' : 'select'
69+
70+
const naiveSelection = isCurrentlySelected
71+
? selectedItems.filter(
72+
(selectedItem) =>
73+
selectedItem.value !== item.value && selectedItem.label !== item.label,
74+
)
75+
: [...selectedItems, item]
76+
77+
const nextSelection = transformSelection
78+
? transformSelection(naiveSelection, item, action)
79+
: naiveSelection
80+
81+
setSelectedItems(nextSelection)
82+
83+
if (isCurrentlySelected) {
6484
onUnselect(item)
6585
} else {
66-
const newSelectedItems = [...selectedItems, item]
67-
setSelectedItems(newSelectedItems)
6886
onSelect(item)
6987
}
7088
},
71-
[selectedItems, onSelect, onUnselect, includesItems],
89+
[selectedItems, onSelect, onUnselect, includesItems, transformSelection],
7290
)
7391

7492
const handleSubmit = useCallback(() => {

source/components/steps/OptionalPackages.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Text } from 'ink'
2-
import { type FC, useEffect, useMemo, useState } from 'react'
3-
import { type Stack, getStackConfig } from '../../constants/config.js'
2+
import { type FC, useCallback, useEffect, useMemo, useState } from 'react'
3+
import { type FeatureName, type Stack, getStackConfig } from '../../constants/config.js'
44
import type { MultiSelectItem } from '../../types/types.js'
5+
import { applyFeatureToggle } from '../../utils/utils.js'
56
import MultiSelect from '../Multiselect/index.js'
67

78
interface Props {
@@ -22,6 +23,27 @@ const OptionalPackages: FC<Props> = ({ stack, onCompletion, onSubmit, skip = fal
2223
}))
2324
}, [stack])
2425

26+
// Keep the selection dependency-consistent as the user toggles (e.g. e2e requires counter).
27+
const transformSelection = useCallback(
28+
(
29+
nextSelected: Array<MultiSelectItem>,
30+
toggledItem: MultiSelectItem,
31+
action: 'select' | 'unselect',
32+
): Array<MultiSelectItem> => {
33+
const selectedValues = nextSelected.map((item) => item.value as FeatureName)
34+
const resolved = applyFeatureToggle(
35+
stack,
36+
selectedValues,
37+
toggledItem.value as FeatureName,
38+
action,
39+
)
40+
return resolved
41+
.map((value) => customPackages.find((pkg) => pkg.value === value))
42+
.filter((pkg): pkg is MultiSelectItem => pkg !== undefined)
43+
},
44+
[stack, customPackages],
45+
)
46+
2547
// biome-ignore lint/correctness/useExhaustiveDependencies: Run this only once, no matter what
2648
useEffect(() => {
2749
if (skip) {
@@ -61,6 +83,7 @@ const OptionalPackages: FC<Props> = ({ stack, onCompletion, onSubmit, skip = fal
6183
focus
6284
items={customPackages}
6385
onSubmit={onHandleSubmit}
86+
transformSelection={transformSelection}
6487
/>
6588
</>
6689
)

source/constants/config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export type FeatureDefinition = {
1717
// Relative paths removed when the feature is deselected (custom mode). Directory paths also
1818
// drive package.json script stripping via scriptTargetsRemovedDir in cleanupFiles.
1919
paths?: string[]
20+
// Other features this one depends on. Selecting it pulls these in; deselecting one of these
21+
// cascades this feature out. One-directional and resolved transitively (see utils.ts).
22+
requires?: FeatureName[]
2023
}
2124

2225
export type EnvFile = {
@@ -130,11 +133,12 @@ export const stackDefinitions: Record<Stack, StackConfig> = {
130133
],
131134
},
132135
e2e: {
133-
description: 'Playwright end-to-end test suite',
136+
description: 'Playwright end-to-end test suite (drives the counter dapp)',
134137
label: 'E2E tests',
135138
packages: [],
136139
default: true,
137140
paths: ['e2e'],
141+
requires: ['counter'],
138142
},
139143
carpincho: {
140144
description: 'Carpincho browser-extension wallet (frontend + build tooling)',

source/info.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ type FeatureInfo = {
44
description: string
55
default: boolean
66
postInstall?: string[]
7+
requires?: string[]
78
}
89

910
type StackInfo = {
@@ -27,6 +28,7 @@ function buildStackInfo(stack: Stack): StackInfo {
2728
description: def.description,
2829
default: def.default,
2930
...(def.postInstall ? { postInstall: def.postInstall } : {}),
31+
...(def.requires ? { requires: def.requires } : {}),
3032
},
3133
]),
3234
),

0 commit comments

Comments
 (0)