create-cmp-cli 0.2.0 → 0.3.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.
Files changed (66) hide show
  1. package/README.md +65 -27
  2. package/bin/create-cmp.mjs +2 -1
  3. package/options.schema.json +5 -2
  4. package/package.json +2 -2
  5. package/src/commands/create.mjs +26 -3
  6. package/src/lib/toggle.mjs +2 -2
  7. package/template/.claude/settings.json +12 -0
  8. package/template/.claude/skills/add-feature/SKILL.md +131 -0
  9. package/template/.claude/skills/add-repository/SKILL.md +106 -0
  10. package/template/.claude/skills/add-screen/SKILL.md +130 -0
  11. package/template/.github/workflows/verify.yml +30 -8
  12. package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
  13. package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
  14. package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
  15. package/template/.gradle/8.11.1/gc.properties +0 -0
  16. package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  17. package/template/.gradle/buildOutputCleanup/cache.properties +2 -0
  18. package/template/.gradle/vcs-1/gc.properties +0 -0
  19. package/template/CHANGELOG.md +14 -0
  20. package/template/CLAUDE.md +70 -0
  21. package/template/CONTRIBUTING.md +39 -0
  22. package/template/README.md +103 -0
  23. package/template/composeApp/build.gradle.kts +15 -8
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +4 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +10 -2
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +10 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +6 -2
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +1 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +2 -1
  30. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +26 -0
  31. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +37 -0
  32. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +104 -0
  33. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +29 -0
  34. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/A11yConformanceTest.kt +64 -0
  35. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +143 -0
  36. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeGoldenTreeTest.kt +78 -0
  37. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +77 -0
  38. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/navigation/AppShellTest.kt +79 -0
  39. package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/ComposeTestExt.kt +16 -0
  40. package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/StructuralTree.kt +63 -0
  41. package/template/docs/ARCHITECTURE.md +51 -0
  42. package/template/docs/TESTING.md +64 -0
  43. package/template/docs/adr/0001-adopt-the-create-cmp-harness-conventions.md +32 -0
  44. package/template/docs/adr/template.md +17 -0
  45. package/template/gitignore +4 -0
  46. package/template/gradle/libs.versions.toml +2 -0
  47. package/template/manifest.json +11 -8
  48. package/template/qa/e2e/README.md +29 -0
  49. package/template/qa/e2e/smoke.yaml +35 -0
  50. package/template/qa/evidence/schema.json +56 -0
  51. package/template/qa/golden/home.json +15 -0
  52. package/template/qa/lib/inputs-hash.mjs +105 -0
  53. package/template/qa/lib/token-drift.mjs +94 -0
  54. package/template/qa/receipt-check.mjs +113 -0
  55. package/template/qa/refusal-demo.mjs +491 -0
  56. package/template/qa/scaffold-feature.mjs +488 -0
  57. package/template/qa/verify.mjs +467 -0
  58. package/template/specs/README.md +33 -0
  59. package/template/specs/app-base.spec.md +30 -0
  60. package/template/specs/home.spec.md +18 -0
  61. package/template/qa/appium/README.md +0 -23
  62. package/template/qa/appium/lib/appium-client.mjs +0 -225
  63. package/template/qa/appium/package.json +0 -8
  64. package/template/qa/appium/run-android-smoke.mjs +0 -39
  65. package/template/tests/appium/cmp/conftest.py +0 -96
  66. package/template/tests/appium/cmp/test_smoke.py +0 -17
@@ -1,225 +0,0 @@
1
- import fs from 'node:fs/promises';
2
-
3
- export class AppiumClient {
4
- constructor({ serverUrl, capabilities }) {
5
- this.serverUrl = serverUrl.replace(/\/$/, '');
6
- this.capabilities = capabilities;
7
- this.sessionId = null;
8
- }
9
-
10
- async start() {
11
- const result = await this.#request('/session', {
12
- method: 'POST',
13
- body: {
14
- capabilities: {
15
- alwaysMatch: this.capabilities,
16
- firstMatch: [{}],
17
- },
18
- },
19
- });
20
- this.sessionId = result.sessionId ?? result.value?.sessionId;
21
- return this.sessionId;
22
- }
23
-
24
- async stop() {
25
- if (!this.sessionId) {
26
- return;
27
- }
28
- await this.#request(`/session/${this.sessionId}`, { method: 'DELETE' });
29
- this.sessionId = null;
30
- }
31
-
32
- async pause(ms) {
33
- await new Promise((resolve) => setTimeout(resolve, ms));
34
- }
35
-
36
- async back() {
37
- await this.#sessionRequest('/back', { method: 'POST', body: {} });
38
- }
39
-
40
- async screenshot(filePath) {
41
- const result = await this.#sessionRequest('/screenshot', { method: 'GET' });
42
- await fs.mkdir(new URL('.', `file://${filePath}`).pathname, { recursive: true }).catch(() => {});
43
- await fs.writeFile(filePath, Buffer.from(result.value, 'base64'));
44
- }
45
-
46
- async pageSource() {
47
- const result = await this.#sessionRequest('/source', { method: 'GET' });
48
- return result.value;
49
- }
50
-
51
- async clickByText(text, timeoutMs = 10000) {
52
- await this.clickByXPath(`//*[@text=${escapeXPathText(text)}]`, timeoutMs);
53
- }
54
-
55
- async clickByTextContaining(text, timeoutMs = 10000) {
56
- await this.clickByXPath(`//*[contains(@text,${escapeXPathText(text)})]`, timeoutMs);
57
- }
58
-
59
- async waitForTextContaining(text, timeoutMs = 10000) {
60
- const safeText = escapeXPathText(text);
61
- return this.waitForElement('xpath', `//*[contains(@text,${safeText})]`, timeoutMs);
62
- }
63
-
64
- async clickByAccessibilityId(label, timeoutMs = 10000) {
65
- const elementId = await this.waitForElement('accessibility id', label, timeoutMs);
66
- await this.clickElement(elementId);
67
- }
68
-
69
- async clickByXPath(xpath, timeoutMs = 10000) {
70
- const elementId = await this.waitForElement('xpath', xpath, timeoutMs);
71
- await this.clickElement(elementId);
72
- }
73
-
74
- async clickElement(elementId) {
75
- await this.#sessionRequest(`/element/${elementId}/click`, { method: 'POST', body: {} });
76
- }
77
-
78
- async waitForText(text, timeoutMs = 10000) {
79
- const safeText = escapeXPathText(text);
80
- return this.waitForElement('xpath', `//*[@text=${safeText}]`, timeoutMs);
81
- }
82
-
83
- async elementExists(using, value) {
84
- try {
85
- await this.#sessionRequest('/element', {
86
- method: 'POST',
87
- body: { using, value },
88
- });
89
- return true;
90
- } catch {
91
- return false;
92
- }
93
- }
94
-
95
- async typeByXPath(xpath, text, timeoutMs = 10000) {
96
- const elementId = await this.waitForElement('xpath', xpath, timeoutMs);
97
- await this.clickElement(elementId);
98
- await this.clearElement(elementId);
99
- await this.typeElement(elementId, text);
100
- return elementId;
101
- }
102
-
103
- async typeIntoLabeledField(label, text, timeoutMs = 10000) {
104
- const safeLabel = escapeXPathText(label);
105
- return this.typeByXPath(`//android.widget.EditText[.//android.widget.TextView[@text=${safeLabel}]]`, text, timeoutMs);
106
- }
107
-
108
- async clearElement(elementId) {
109
- await this.#sessionRequest(`/element/${elementId}/clear`, { method: 'POST', body: {} });
110
- }
111
-
112
- async typeElement(elementId, text) {
113
- await this.#sessionRequest(`/element/${elementId}/value`, {
114
- method: 'POST',
115
- body: {
116
- text,
117
- value: Array.from(text),
118
- },
119
- });
120
- }
121
-
122
- async waitForTextGone(text, timeoutMs = 10000) {
123
- const safeText = escapeXPathText(text);
124
- const startedAt = Date.now();
125
- while (Date.now() - startedAt < timeoutMs) {
126
- const exists = await this.elementExists('xpath', `//*[@text=${safeText}]`);
127
- if (!exists) {
128
- return;
129
- }
130
- await this.pause(300);
131
- }
132
- throw new Error(`Timed out waiting for text to disappear: ${text}`);
133
- }
134
-
135
- async getWindowRect() {
136
- const result = await this.#sessionRequest('/window/rect', { method: 'GET' });
137
- return result.value;
138
- }
139
-
140
- async swipe(startX, startY, endX, endY, durationMs = 350) {
141
- await this.#sessionRequest('/actions', {
142
- method: 'POST',
143
- body: {
144
- actions: [
145
- {
146
- type: 'pointer',
147
- id: 'finger1',
148
- parameters: { pointerType: 'touch' },
149
- actions: [
150
- { type: 'pointerMove', duration: 0, x: startX, y: startY },
151
- { type: 'pointerDown', button: 0 },
152
- { type: 'pause', duration: 100 },
153
- { type: 'pointerMove', duration: durationMs, x: endX, y: endY },
154
- { type: 'pointerUp', button: 0 },
155
- ],
156
- },
157
- ],
158
- },
159
- });
160
- await this.#sessionRequest('/actions', { method: 'DELETE', body: {} });
161
- }
162
-
163
- async swipeUp() {
164
- const rect = await this.getWindowRect();
165
- const midX = Math.round(rect.width / 2);
166
- await this.swipe(midX, Math.round(rect.height * 0.8), midX, Math.round(rect.height * 0.35));
167
- }
168
-
169
- async waitForElement(using, value, timeoutMs = 10000) {
170
- const startedAt = Date.now();
171
- let lastError = null;
172
- while (Date.now() - startedAt < timeoutMs) {
173
- try {
174
- const result = await this.#sessionRequest('/element', {
175
- method: 'POST',
176
- body: { using, value },
177
- });
178
- const element = result.value;
179
- const elementId = element?.['element-6066-11e4-a52e-4f735466cecf'] ?? element?.ELEMENT;
180
- if (elementId) {
181
- return elementId;
182
- }
183
- } catch (error) {
184
- lastError = error;
185
- }
186
- await this.pause(350);
187
- }
188
- throw new Error(`Timed out waiting for element ${using}=${value}: ${lastError?.message ?? 'not found'}`);
189
- }
190
-
191
- async #sessionRequest(path, options) {
192
- if (!this.sessionId) {
193
- throw new Error('Appium session has not been started');
194
- }
195
- return this.#request(`/session/${this.sessionId}${path}`, options);
196
- }
197
-
198
- async #request(path, { method, body } = {}) {
199
- const response = await fetch(`${this.serverUrl}${path}`, {
200
- method,
201
- headers: {
202
- 'Content-Type': 'application/json',
203
- },
204
- body: body === undefined ? undefined : JSON.stringify(body),
205
- });
206
- const text = await response.text();
207
- const json = text ? JSON.parse(text) : {};
208
- if (!response.ok) {
209
- const message = json.value?.message ?? json.error ?? text;
210
- throw new Error(message);
211
- }
212
- return json;
213
- }
214
- }
215
-
216
- export function escapeXPathText(text) {
217
- if (!text.includes("'")) {
218
- return `'${text}'`;
219
- }
220
- if (!text.includes('"')) {
221
- return `"${text}"`;
222
- }
223
- const parts = text.split("'").map((part) => `'${part}'`);
224
- return `concat(${parts.join(', "\'", ')})`;
225
- }
@@ -1,8 +0,0 @@
1
- {
2
- "name": "__APP_NAME__-appium",
3
- "private": true,
4
- "type": "module",
5
- "scripts": {
6
- "smoke": "node run-android-smoke.mjs"
7
- }
8
- }
@@ -1,39 +0,0 @@
1
- // Android smoke runner — asserts the app launches and the bottom-nav shell renders.
2
- // Prereqs: an Appium 3.x server on http://127.0.0.1:4723 and a running emulator-5554
3
- // with the debug APK installed (./gradlew :composeApp:installDebug).
4
- import { AppiumClient } from './lib/appium-client.mjs';
5
-
6
- const APP_PACKAGE = '__PACKAGE__';
7
- const APP_ACTIVITY = '__PACKAGE__.MainActivity';
8
-
9
- const client = new AppiumClient({
10
- serverUrl: 'http://127.0.0.1:4723',
11
- capabilities: {
12
- platformName: 'Android',
13
- 'appium:automationName': 'UiAutomator2',
14
- 'appium:deviceName': 'emulator-5554',
15
- 'appium:udid': 'emulator-5554',
16
- 'appium:appPackage': APP_PACKAGE,
17
- 'appium:appActivity': APP_ACTIVITY,
18
- 'appium:forceAppLaunch': true,
19
- 'appium:newCommandTimeout': 120,
20
- },
21
- });
22
-
23
- async function main() {
24
- await client.start();
25
- try {
26
- await client.waitForText('Home', 20000);
27
- await client.waitForText('Profile', 10000);
28
- await client.clickByText('Profile', 10000);
29
- await client.waitForTextContaining('stub screen', 10000);
30
- console.log('SMOKE PASS: bottom-nav shell rendered and tab switch works.');
31
- } finally {
32
- await client.stop();
33
- }
34
- }
35
-
36
- main().catch((err) => {
37
- console.error('SMOKE FAIL:', err.message);
38
- process.exit(1);
39
- });
@@ -1,96 +0,0 @@
1
- """
2
- Shared Appium session configuration for the __APP_NAME__ Android app.
3
- Appium 3.x + UiAutomator2. Start an Appium server on 127.0.0.1:4723 and a running
4
- emulator-5554 before invoking pytest.
5
- """
6
- import base64
7
- import os
8
- import re
9
- import time
10
-
11
- import pytest
12
- import requests
13
-
14
- APPIUM_URL = "http://127.0.0.1:4723"
15
- CAPABILITIES = {
16
- "platformName": "Android",
17
- "appium:automationName": "UiAutomator2",
18
- "appium:deviceName": "emulator-5554",
19
- "appium:udid": "emulator-5554",
20
- "appium:appPackage": "__PACKAGE__",
21
- "appium:appActivity": "__PACKAGE__.MainActivity",
22
- "appium:noReset": False,
23
- "appium:forceAppLaunch": True,
24
- "appium:newCommandTimeout": 120,
25
- }
26
- SCREENSHOTS_DIR = "qa-artifacts/appium-cmp"
27
-
28
-
29
- def escape_xpath(text: str) -> str:
30
- if "'" not in text:
31
- return f"'{text}'"
32
- if '"' not in text:
33
- return f'"{text}"'
34
- parts = text.split("'")
35
- return "concat('" + "', \"'\", '".join(parts) + "')"
36
-
37
-
38
- class AppiumDriver:
39
- def __init__(self, session_id: str):
40
- self.session_id = session_id
41
- self.base = f"{APPIUM_URL}/session/{session_id}"
42
-
43
- def _req(self, method: str, path: str, body=None, timeout: int = 30) -> dict:
44
- resp = requests.request(method, self.base + path, json=body, timeout=timeout)
45
- return resp.json() if resp.content else {}
46
-
47
- def source(self) -> str:
48
- return self._req("GET", "/source").get("value", "")
49
-
50
- def _find_xpath(self, xpath: str, timeout: int = 10) -> str:
51
- deadline = time.time() + timeout
52
- while time.time() < deadline:
53
- r = requests.post(self.base + "/element",
54
- json={"using": "xpath", "value": xpath}, timeout=10)
55
- if r.status_code == 200:
56
- val = r.json().get("value", {})
57
- if isinstance(val, dict) and val:
58
- return next(iter(val.values()))
59
- time.sleep(0.5)
60
- raise TimeoutError(f"Element not found: {xpath}")
61
-
62
- def find_by_text(self, text: str, timeout: int = 10) -> str:
63
- return self._find_xpath(f"//*[@text={escape_xpath(text)}]", timeout)
64
-
65
- def text_exists(self, text: str, timeout: int = 5) -> bool:
66
- try:
67
- self.find_by_text(text, timeout)
68
- return True
69
- except TimeoutError:
70
- return False
71
-
72
- def click_text(self, text: str, timeout: int = 10):
73
- self._req("POST", f"/element/{self.find_by_text(text, timeout)}/click")
74
-
75
- def screenshot(self, name: str = "screenshot") -> str:
76
- os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
77
- r = self._req("GET", "/screenshot", timeout=15)
78
- if r.get("value"):
79
- path = f"{SCREENSHOTS_DIR}/{name}_{int(time.time())}.png"
80
- with open(path, "wb") as f:
81
- f.write(base64.b64decode(r["value"]))
82
- return path
83
- return ""
84
-
85
-
86
- @pytest.fixture(scope="module")
87
- def driver():
88
- resp = requests.post(f"{APPIUM_URL}/session",
89
- json={"capabilities": {"alwaysMatch": CAPABILITIES}}, timeout=60)
90
- resp.raise_for_status()
91
- data = resp.json()
92
- session_id = data.get("sessionId") or data.get("value", {}).get("sessionId")
93
- time.sleep(5)
94
- drv = AppiumDriver(session_id)
95
- yield drv
96
- requests.delete(f"{APPIUM_URL}/session/{session_id}", timeout=15)
@@ -1,17 +0,0 @@
1
- """
2
- Smoke test: the app launches and the generic bottom-nav shell renders.
3
- This is the `--verify` Appium gate the scaffolder runs to prove the stamped app is alive.
4
- """
5
-
6
-
7
- def test_bottom_nav_renders(driver):
8
- # Home tab title is present on first launch.
9
- assert driver.text_exists("Home", timeout=20), "Home screen did not render"
10
- # Both default tabs are present in the bottom nav.
11
- assert driver.text_exists("Profile", timeout=10), "Profile tab not found in bottom nav"
12
-
13
-
14
- def test_can_switch_to_profile_tab(driver):
15
- driver.click_text("Profile", timeout=10)
16
- assert driver.text_exists("This is a stub screen.", timeout=10), \
17
- "Profile tab content did not render after tapping the tab"