@sun-asterisk/sungen 3.2.2-beta.10 → 3.2.2-beta.13
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/dist/capabilities/discover.d.ts.map +1 -1
- package/dist/capabilities/discover.js +26 -3
- package/dist/capabilities/discover.js.map +1 -1
- package/dist/cli/commands/add-flow.d.ts.map +1 -1
- package/dist/cli/commands/add-flow.js +2 -0
- package/dist/cli/commands/add-flow.js.map +1 -1
- package/dist/cli/commands/add.d.ts.map +1 -1
- package/dist/cli/commands/add.js +4 -0
- package/dist/cli/commands/add.js.map +1 -1
- package/dist/cli/commands/capability.d.ts.map +1 -1
- package/dist/cli/commands/capability.js +61 -0
- package/dist/cli/commands/capability.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +89 -2
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +2 -1
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +27 -2
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
- package/dist/dashboard/snapshot-builder.js +10 -1
- package/dist/dashboard/snapshot-builder.js.map +1 -1
- package/dist/exporters/csv-exporter.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.js +3 -1
- package/dist/exporters/csv-exporter.js.map +1 -1
- package/dist/exporters/json-exporter.js +3 -1
- package/dist/exporters/json-exporter.js.map +1 -1
- package/dist/exporters/mobile-app-id.d.ts +22 -0
- package/dist/exporters/mobile-app-id.d.ts.map +1 -0
- package/dist/exporters/mobile-app-id.js +37 -0
- package/dist/exporters/mobile-app-id.js.map +1 -0
- package/dist/exporters/spec-parser.d.ts.map +1 -1
- package/dist/exporters/spec-parser.js +6 -2
- package/dist/exporters/spec-parser.js.map +1 -1
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts +3 -0
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs +7 -4
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs +20 -3
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/checked-assertion.hbs +4 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/not-checked-assertion.hbs +4 -1
- package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +3 -0
- package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
- package/dist/generators/test-generator/code-generator.js +38 -3
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/template-engine.d.ts +3 -0
- package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
- package/dist/generators/test-generator/template-engine.js +11 -1
- package/dist/generators/test-generator/template-engine.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts +11 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +33 -0
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/dist/harness/audit.js +1 -1
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/catalog/universal-viewpoints.yaml +15 -0
- package/dist/harness/journey.d.ts.map +1 -1
- package/dist/harness/journey.js +22 -11
- package/dist/harness/journey.js.map +1 -1
- package/dist/harness/sensors.d.ts +1 -1
- package/dist/harness/sensors.d.ts.map +1 -1
- package/dist/harness/sensors.js +32 -9
- package/dist/harness/sensors.js.map +1 -1
- package/dist/orchestrator/flow-manager.d.ts +11 -0
- package/dist/orchestrator/flow-manager.d.ts.map +1 -1
- package/dist/orchestrator/flow-manager.js +147 -29
- package/dist/orchestrator/flow-manager.js.map +1 -1
- package/dist/orchestrator/mobile-runtime-scaffolder.d.ts +59 -0
- package/dist/orchestrator/mobile-runtime-scaffolder.d.ts.map +1 -0
- package/dist/orchestrator/mobile-runtime-scaffolder.js +298 -0
- package/dist/orchestrator/mobile-runtime-scaffolder.js.map +1 -0
- package/dist/orchestrator/project-initializer.d.ts +25 -7
- package/dist/orchestrator/project-initializer.d.ts.map +1 -1
- package/dist/orchestrator/project-initializer.js +115 -62
- package/dist/orchestrator/project-initializer.js.map +1 -1
- package/dist/orchestrator/screen-manager.d.ts +30 -0
- package/dist/orchestrator/screen-manager.d.ts.map +1 -1
- package/dist/orchestrator/screen-manager.js +205 -22
- package/dist/orchestrator/screen-manager.js.map +1 -1
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-add-flow.md +32 -4
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-add-screen.md +51 -10
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +12 -1
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-delivery.md +6 -0
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-locale.md +25 -4
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-review.md +7 -0
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +38 -4
- package/dist/orchestrator/templates/ai-instructions/claude-skill-delivery.md +2 -0
- package/dist/orchestrator/templates/ai-instructions/claude-skill-locale.md +106 -9
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-add-flow.md +33 -3
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-add-screen.md +50 -9
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +9 -1
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-delivery.md +6 -0
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-locale.md +25 -4
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-review.md +7 -0
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +38 -4
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-delivery.md +2 -0
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-locale.md +106 -9
- package/package.json +3 -3
- package/src/capabilities/discover.ts +29 -3
- package/src/cli/commands/add-flow.ts +2 -0
- package/src/cli/commands/add.ts +4 -0
- package/src/cli/commands/capability.ts +64 -1
- package/src/cli/commands/delivery.ts +89 -2
- package/src/cli/commands/generate.ts +2 -1
- package/src/cli/commands/init.ts +38 -3
- package/src/dashboard/snapshot-builder.ts +10 -1
- package/src/exporters/csv-exporter.ts +3 -1
- package/src/exporters/json-exporter.ts +3 -1
- package/src/exporters/mobile-app-id.ts +34 -0
- package/src/exporters/spec-parser.ts +6 -2
- package/src/generators/test-generator/adapters/adapter-interface.ts +3 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs +7 -4
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs +20 -3
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/checked-assertion.hbs +4 -1
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/not-checked-assertion.hbs +4 -1
- package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +3 -0
- package/src/generators/test-generator/code-generator.ts +39 -3
- package/src/generators/test-generator/template-engine.ts +14 -1
- package/src/generators/test-generator/utils/selector-resolver.ts +51 -1
- package/src/harness/audit.ts +1 -1
- package/src/harness/catalog/universal-viewpoints.yaml +15 -0
- package/src/harness/journey.ts +21 -9
- package/src/harness/sensors.ts +25 -8
- package/src/orchestrator/flow-manager.ts +165 -29
- package/src/orchestrator/mobile-runtime-scaffolder.ts +277 -0
- package/src/orchestrator/project-initializer.ts +127 -64
- package/src/orchestrator/screen-manager.ts +237 -22
- package/src/orchestrator/templates/ai-instructions/claude-cmd-add-flow.md +32 -4
- package/src/orchestrator/templates/ai-instructions/claude-cmd-add-screen.md +51 -10
- package/src/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +12 -1
- package/src/orchestrator/templates/ai-instructions/claude-cmd-delivery.md +6 -0
- package/src/orchestrator/templates/ai-instructions/claude-cmd-locale.md +25 -4
- package/src/orchestrator/templates/ai-instructions/claude-cmd-review.md +7 -0
- package/src/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +38 -4
- package/src/orchestrator/templates/ai-instructions/claude-skill-delivery.md +2 -0
- package/src/orchestrator/templates/ai-instructions/claude-skill-locale.md +106 -9
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-add-flow.md +33 -3
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-add-screen.md +50 -9
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +9 -1
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-delivery.md +6 -0
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-locale.md +25 -4
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-review.md +7 -0
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +38 -4
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-delivery.md +2 -0
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-locale.md +106 -9
|
@@ -7,16 +7,31 @@ import * as fs from 'fs';
|
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import { execSync } from 'child_process';
|
|
9
9
|
import { AI_RULES_FILE_MAPPING } from './ai-rules-updater';
|
|
10
|
+
import {
|
|
11
|
+
MobileInitOptions,
|
|
12
|
+
emitMobileRuntime,
|
|
13
|
+
writeMobileCapabilities,
|
|
14
|
+
installMobileDriver,
|
|
15
|
+
} from './mobile-runtime-scaffolder';
|
|
16
|
+
|
|
17
|
+
export { MobileInitOptions };
|
|
10
18
|
|
|
11
19
|
export class ProjectInitializer {
|
|
12
20
|
private baseCwd: string;
|
|
13
21
|
private cwd: string;
|
|
14
22
|
private createdItems: string[] = [];
|
|
15
23
|
private skippedItems: string[] = [];
|
|
24
|
+
private mobile: MobileInitOptions;
|
|
16
25
|
|
|
17
|
-
constructor(private baseUrl: string) {
|
|
26
|
+
constructor(private baseUrl: string, mobile?: MobileInitOptions) {
|
|
18
27
|
this.baseCwd = process.cwd();
|
|
19
28
|
this.cwd = this.baseCwd;
|
|
29
|
+
this.mobile = mobile ?? { enabled: false };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Mobile-only project: no Playwright/web scaffolding. */
|
|
33
|
+
private get mobileOnly(): boolean {
|
|
34
|
+
return !!this.mobile.mobileOnly;
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
/**
|
|
@@ -30,34 +45,41 @@ export class ProjectInitializer {
|
|
|
30
45
|
this.createdItems = [];
|
|
31
46
|
this.skippedItems = [];
|
|
32
47
|
|
|
33
|
-
console.log(
|
|
48
|
+
console.log(`🚀 Initializing Sungen ${this.mobileOnly ? 'mobile-only ' : ''}project...\n`);
|
|
34
49
|
|
|
35
50
|
if (normalizedProjectName) {
|
|
36
51
|
this.createProjectRoot(normalizedProjectName);
|
|
37
52
|
}
|
|
38
53
|
|
|
39
|
-
// Create directories
|
|
54
|
+
// Create directories (mobile-only skips the web Playwright storage dir)
|
|
40
55
|
this.createDirectories();
|
|
41
56
|
|
|
42
57
|
// Create qa/context.md for QA lead to fill project-wide context
|
|
43
58
|
this.createContext();
|
|
44
59
|
|
|
45
|
-
// Create qa/capabilities.yaml —
|
|
60
|
+
// Create qa/capabilities.yaml — web default; mobile path uses writeMobileCapabilities
|
|
46
61
|
this.createCapabilities();
|
|
47
62
|
|
|
48
|
-
// Ensure package.json and install Playwright
|
|
63
|
+
// Ensure package.json and install deps (Playwright for web; skipped for mobile-only)
|
|
49
64
|
await this.setupDependencies();
|
|
50
65
|
|
|
51
|
-
//
|
|
52
|
-
this.
|
|
66
|
+
// Web-only scaffolding — skipped entirely for a mobile-only project
|
|
67
|
+
if (!this.mobileOnly) {
|
|
68
|
+
this.createPlaywrightConfig();
|
|
69
|
+
this.createSpecsBase();
|
|
70
|
+
}
|
|
53
71
|
|
|
54
|
-
// Create tsconfig.json if doesn't exist
|
|
72
|
+
// Create tsconfig.json if doesn't exist (needed by both web + mobile)
|
|
55
73
|
this.createTsConfig();
|
|
56
74
|
|
|
57
|
-
//
|
|
58
|
-
this.createSpecsBase();
|
|
75
|
+
// specs/test-data.ts (runtime YAML loader) — shared by BOTH web and mobile specs
|
|
59
76
|
this.createSpecsTestData();
|
|
60
77
|
|
|
78
|
+
// Mobile harness (Appium/WebdriverIO) — when `--mobile` (a mobile-only project)
|
|
79
|
+
if (this.mobile.enabled) {
|
|
80
|
+
this.emitMobileHarness();
|
|
81
|
+
}
|
|
82
|
+
|
|
61
83
|
// Create/update .gitignore
|
|
62
84
|
this.updateGitignore();
|
|
63
85
|
|
|
@@ -90,17 +112,14 @@ export class ProjectInitializer {
|
|
|
90
112
|
}
|
|
91
113
|
|
|
92
114
|
/**
|
|
93
|
-
* Create essential directories only
|
|
115
|
+
* Create essential directories only.
|
|
116
|
+
* Mobile-only: skips specs/storage (Playwright auth state); qa/screens + qa/flows still created
|
|
117
|
+
* (1-platform model — mobile uses the same tree as web, NOT a separate qa/mobile/ path).
|
|
94
118
|
*/
|
|
95
119
|
private createDirectories(): void {
|
|
96
|
-
const dirs =
|
|
97
|
-
'qa',
|
|
98
|
-
'qa/screens',
|
|
99
|
-
'qa/flows',
|
|
100
|
-
'specs',
|
|
101
|
-
'specs/generated',
|
|
102
|
-
'specs/storage',
|
|
103
|
-
];
|
|
120
|
+
const dirs = this.mobileOnly
|
|
121
|
+
? ['qa', 'qa/screens', 'qa/flows', 'specs', 'specs/generated']
|
|
122
|
+
: ['qa', 'qa/screens', 'qa/flows', 'specs', 'specs/generated', 'specs/storage'];
|
|
104
123
|
|
|
105
124
|
for (const dir of dirs) {
|
|
106
125
|
const fullPath = path.join(this.cwd, dir);
|
|
@@ -147,13 +166,17 @@ export class ProjectInitializer {
|
|
|
147
166
|
}
|
|
148
167
|
|
|
149
168
|
/**
|
|
150
|
-
* Update .gitignore with Sungen-specific entries
|
|
169
|
+
* Update .gitignore with Sungen-specific entries.
|
|
170
|
+
* Mobile projects add .env.appium (device ids + machine paths) and specs/.auth-mobile/
|
|
171
|
+
* (Appium auth contract). Web projects add Playwright auth state + report dirs.
|
|
151
172
|
*/
|
|
152
173
|
private updateGitignore(): void {
|
|
153
174
|
const gitignorePath = path.join(this.cwd, '.gitignore');
|
|
154
175
|
const sungenEntries = [
|
|
155
176
|
'# Sungen generated files',
|
|
156
177
|
'specs/.auth/', // session storage state = cookies/tokens (sensitive)
|
|
178
|
+
'specs/.auth-mobile/', // mobile auth contract = credentials (sensitive)
|
|
179
|
+
'.env.appium', // local mobile-test env = machine paths + device/team ids (.example IS committed)
|
|
157
180
|
'test-results/',
|
|
158
181
|
'playwright-report/',
|
|
159
182
|
'.playwright-mcp/',
|
|
@@ -199,7 +222,8 @@ export class ProjectInitializer {
|
|
|
199
222
|
}
|
|
200
223
|
|
|
201
224
|
/**
|
|
202
|
-
* Create VS Code settings for Copilot auto-attach
|
|
225
|
+
* Create VS Code settings for Copilot auto-attach.
|
|
226
|
+
* Mobile-only: approves wdio + npx appium instead of npx playwright.
|
|
203
227
|
*/
|
|
204
228
|
private createVSCodeSettings(): void {
|
|
205
229
|
const settingsPath = path.join(this.cwd, '.vscode', 'settings.json');
|
|
@@ -214,7 +238,9 @@ export class ProjectInitializer {
|
|
|
214
238
|
'github.copilot.chat.agent.runTasks': true,
|
|
215
239
|
'chat.tools.terminal.autoApprove': {
|
|
216
240
|
sungen: true,
|
|
217
|
-
|
|
241
|
+
...(this.mobileOnly
|
|
242
|
+
? { wdio: true, 'npx appium': true }
|
|
243
|
+
: { 'npx playwright': true }),
|
|
218
244
|
},
|
|
219
245
|
};
|
|
220
246
|
|
|
@@ -224,15 +250,14 @@ export class ProjectInitializer {
|
|
|
224
250
|
this.skippedItems.push('.vscode/settings.json');
|
|
225
251
|
}
|
|
226
252
|
|
|
227
|
-
// .vscode/mcp.json (Copilot format) —
|
|
253
|
+
// .vscode/mcp.json (Copilot format) — appium-mcp for mobile-only, Playwright MCP otherwise
|
|
228
254
|
this.mergeMCPConfig(
|
|
229
255
|
path.join(this.cwd, '.vscode', 'mcp.json'),
|
|
230
256
|
'servers',
|
|
231
257
|
{
|
|
232
|
-
|
|
233
|
-
command: 'npx',
|
|
234
|
-
args: ['@playwright/mcp@latest'],
|
|
235
|
-
},
|
|
258
|
+
...(this.mobileOnly
|
|
259
|
+
? { 'appium-mcp': { command: 'npx', args: ['-y', 'appium-mcp'] } }
|
|
260
|
+
: { playwright: { command: 'npx', args: ['@playwright/mcp@latest'] } }),
|
|
236
261
|
figma: {
|
|
237
262
|
type: 'http',
|
|
238
263
|
url: 'https://mcp.figma.com/mcp',
|
|
@@ -243,18 +268,17 @@ export class ProjectInitializer {
|
|
|
243
268
|
}
|
|
244
269
|
|
|
245
270
|
/**
|
|
246
|
-
* Create root .mcp.json for Claude Code
|
|
271
|
+
* Create root .mcp.json for Claude Code.
|
|
272
|
+
* Mobile-only: appium-mcp instead of Playwright MCP. Figma always included.
|
|
247
273
|
*/
|
|
248
274
|
private createClaudeMCPConfig(): void {
|
|
249
275
|
this.mergeMCPConfig(
|
|
250
276
|
path.join(this.cwd, '.mcp.json'),
|
|
251
277
|
'mcpServers',
|
|
252
278
|
{
|
|
253
|
-
|
|
254
|
-
type: 'stdio',
|
|
255
|
-
command: 'npx',
|
|
256
|
-
args: ['@playwright/mcp@latest'],
|
|
257
|
-
},
|
|
279
|
+
...(this.mobileOnly
|
|
280
|
+
? { 'appium-mcp': { type: 'stdio', command: 'npx', args: ['-y', 'appium-mcp'] } }
|
|
281
|
+
: { playwright: { type: 'stdio', command: 'npx', args: ['@playwright/mcp@latest'] } }),
|
|
258
282
|
figma: {
|
|
259
283
|
type: 'http',
|
|
260
284
|
url: 'https://mcp.figma.com/mcp',
|
|
@@ -337,10 +361,18 @@ export class ProjectInitializer {
|
|
|
337
361
|
if (projectName) {
|
|
338
362
|
console.log(` ${stepIndex++}. cd ${projectName}`);
|
|
339
363
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
364
|
+
if (this.mobileOnly) {
|
|
365
|
+
console.log(` ${stepIndex++}. Fill in .env.appium with your device/app details (see .env.appium.example)`);
|
|
366
|
+
console.log(` ${stepIndex++}. Create your first screen: sungen add --screen <name>`);
|
|
367
|
+
console.log(` ${stepIndex++}. Use AI (Copilot/Claude) to generate Gherkin + selectors from the live app`);
|
|
368
|
+
console.log(` ${stepIndex++}. Compile tests: sungen generate --screen <name>`);
|
|
369
|
+
console.log(` ${stepIndex++}. Run tests: npm run test:mobile\n`);
|
|
370
|
+
} else {
|
|
371
|
+
console.log(` ${stepIndex++}. Create your first screen: sungen add --screen <name> --path <url-path>`);
|
|
372
|
+
console.log(` ${stepIndex++}. Use AI (Copilot/Claude) to generate Gherkin + selectors from the live page`);
|
|
373
|
+
console.log(` ${stepIndex++}. Compile tests: sungen generate --screen <name>`);
|
|
374
|
+
console.log(` ${stepIndex++}. Run tests: npx playwright test\n`);
|
|
375
|
+
}
|
|
344
376
|
}
|
|
345
377
|
|
|
346
378
|
/**
|
|
@@ -390,8 +422,9 @@ export class ProjectInitializer {
|
|
|
390
422
|
}
|
|
391
423
|
|
|
392
424
|
/**
|
|
393
|
-
* Create qa/capabilities.yaml — the platform driver profile
|
|
394
|
-
*
|
|
425
|
+
* Create qa/capabilities.yaml — the platform driver profile.
|
|
426
|
+
* Web default: platform=web, enabled=[web].
|
|
427
|
+
* Mobile: platform=mobile, enabled=[mobile] (via writeMobileCapabilities from the scaffolder).
|
|
395
428
|
*/
|
|
396
429
|
private createCapabilities(): void {
|
|
397
430
|
const p = path.join(this.cwd, 'qa', 'capabilities.yaml');
|
|
@@ -399,13 +432,18 @@ export class ProjectInitializer {
|
|
|
399
432
|
this.skippedItems.push('qa/capabilities.yaml');
|
|
400
433
|
return;
|
|
401
434
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
435
|
+
|
|
436
|
+
if (this.mobile.enabled) {
|
|
437
|
+
writeMobileCapabilities(this.cwd);
|
|
438
|
+
} else {
|
|
439
|
+
const content =
|
|
440
|
+
'# Sungen capability profile — which runtime/drivers this project uses.\n' +
|
|
441
|
+
'# platform: the runtime adapter — web → Playwright, mobile → Appium.\n' +
|
|
442
|
+
'# enabled: drivers turned on (add more via `sungen capability add <driver>`).\n\n' +
|
|
443
|
+
'platform: web\n' +
|
|
444
|
+
'enabled:\n - web\n';
|
|
445
|
+
fs.writeFileSync(p, content, 'utf-8');
|
|
446
|
+
}
|
|
409
447
|
this.createdItems.push('qa/capabilities.yaml');
|
|
410
448
|
}
|
|
411
449
|
|
|
@@ -451,6 +489,20 @@ export class ProjectInitializer {
|
|
|
451
489
|
this.createdItems.push('specs/generated/test-data.ts');
|
|
452
490
|
}
|
|
453
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Delegate to mobile-runtime-scaffolder to emit Appium/WDIO harness files,
|
|
494
|
+
* then best-effort install @sungen/driver-mobile at lockstep version.
|
|
495
|
+
* Called only when `--mobile` is set.
|
|
496
|
+
*/
|
|
497
|
+
private emitMobileHarness(): void {
|
|
498
|
+
const result = emitMobileRuntime(this.cwd, this.mobile);
|
|
499
|
+
this.createdItems.push(...result.created);
|
|
500
|
+
this.skippedItems.push(...result.skipped);
|
|
501
|
+
|
|
502
|
+
// Best-effort driver install — non-fatal so offline init still scaffolds.
|
|
503
|
+
installMobileDriver(this.cwd);
|
|
504
|
+
}
|
|
505
|
+
|
|
454
506
|
/**
|
|
455
507
|
* Read a template file from the templates directory
|
|
456
508
|
*/
|
|
@@ -476,8 +528,11 @@ export class ProjectInitializer {
|
|
|
476
528
|
// Ensure standard scripts exist in package.json
|
|
477
529
|
this.ensurePackageScripts(packageJsonPath);
|
|
478
530
|
|
|
479
|
-
//
|
|
480
|
-
const requiredDeps =
|
|
531
|
+
// Mobile-only drops @playwright/test (no web runner needed).
|
|
532
|
+
const requiredDeps = this.mobileOnly
|
|
533
|
+
? ['@types/node', 'yaml']
|
|
534
|
+
: ['@playwright/test', '@types/node', 'yaml'];
|
|
535
|
+
|
|
481
536
|
let missingDeps: string[] = requiredDeps;
|
|
482
537
|
try {
|
|
483
538
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
|
@@ -489,15 +544,17 @@ export class ProjectInitializer {
|
|
|
489
544
|
|
|
490
545
|
if (missingDeps.length === 0) {
|
|
491
546
|
console.log('✓ All dependencies already installed\n');
|
|
492
|
-
|
|
547
|
+
} else {
|
|
548
|
+
console.log(`📦 Installing ${missingDeps.join(', ')}...\n`);
|
|
549
|
+
execSync(`npm install -D ${missingDeps.join(' ')}`, execOpts);
|
|
493
550
|
}
|
|
494
551
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
552
|
+
// Chromium is only needed for the web (Playwright) runner — skip for mobile-only projects.
|
|
553
|
+
if (!this.mobileOnly) {
|
|
554
|
+
console.log('\n🎭 Installing Chromium (default browser)...\n');
|
|
555
|
+
execSync('npx playwright install --with-deps chromium', execOpts);
|
|
556
|
+
console.log('\n💡 To install other browsers: npm run install:browsers -- firefox webkit\n');
|
|
557
|
+
}
|
|
501
558
|
}
|
|
502
559
|
|
|
503
560
|
/**
|
|
@@ -516,15 +573,21 @@ export class ProjectInitializer {
|
|
|
516
573
|
packageJson.scripts = {};
|
|
517
574
|
}
|
|
518
575
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
576
|
+
// Mobile-only projects get just the generate + mobile-run scripts (no Playwright web scripts).
|
|
577
|
+
const standardScripts: Record<string, string> = this.mobileOnly
|
|
578
|
+
? {
|
|
579
|
+
'generate': 'sungen generate --all',
|
|
580
|
+
'test:mobile': 'wdio run wdio.conf.ts',
|
|
581
|
+
}
|
|
582
|
+
: {
|
|
583
|
+
'test': 'playwright test specs/generated/',
|
|
584
|
+
'test:headed': 'playwright test specs/generated/ --headed',
|
|
585
|
+
'test:debug': 'playwright test specs/generated/ --debug',
|
|
586
|
+
'test:ui': 'playwright test specs/generated/ --ui',
|
|
587
|
+
'report': 'playwright show-report',
|
|
588
|
+
'generate': 'sungen generate --all',
|
|
589
|
+
'install:browsers': 'npx playwright install',
|
|
590
|
+
};
|
|
528
591
|
|
|
529
592
|
let added = 0;
|
|
530
593
|
for (const [name, command] of Object.entries(standardScripts)) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import * as fs from 'fs';
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import { chromium } from '@playwright/test';
|
|
9
|
+
import { readCapabilities } from '../harness/capability';
|
|
9
10
|
|
|
10
11
|
export interface ScreenOptions {
|
|
11
12
|
name: string;
|
|
@@ -13,6 +14,20 @@ export interface ScreenOptions {
|
|
|
13
14
|
capture?: boolean;
|
|
14
15
|
feature?: string;
|
|
15
16
|
description?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Optional per-OS tag emitter: 'android' | 'ios'. When set, the feature stub
|
|
19
|
+
* gets @platform:android or @platform:ios for OS-specific screens. Does NOT
|
|
20
|
+
* affect directory routing (always qa/screens/). Mobile project is detected via
|
|
21
|
+
* qa/capabilities.yaml, not this flag.
|
|
22
|
+
*/
|
|
23
|
+
platform?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Mobile navigation recipe (the in-app web-path analog). A `>`-separated list of nav steps
|
|
26
|
+
* that take the app from launch to THIS screen, e.g. "tap [My Page] tab > tap [Settings] row".
|
|
27
|
+
* Scaffolded into the feature's Background after the `Given User is on [Home] screen` anchor.
|
|
28
|
+
* Mobile-only; ignored (with a warning) for web projects. Omit when this IS the launcher screen.
|
|
29
|
+
*/
|
|
30
|
+
reach?: string;
|
|
16
31
|
}
|
|
17
32
|
|
|
18
33
|
export class ScreenManager {
|
|
@@ -31,15 +46,31 @@ export class ScreenManager {
|
|
|
31
46
|
// Validate screen name
|
|
32
47
|
this.validateScreenName(options.name);
|
|
33
48
|
|
|
49
|
+
// Detect project platform from qa/capabilities.yaml (single source of truth)
|
|
50
|
+
const caps = readCapabilities(this.cwd);
|
|
51
|
+
const isMobile = caps.platform === 'mobile';
|
|
52
|
+
|
|
53
|
+
// --reach is mobile-only; warn and drop when the project is web
|
|
54
|
+
if (options.reach && !isMobile) {
|
|
55
|
+
console.warn(
|
|
56
|
+
`Warning: --reach is a mobile navigation recipe and is ignored for web projects ` +
|
|
57
|
+
`(web screens are addressed by their --path URL). Dropping it.`
|
|
58
|
+
);
|
|
59
|
+
options = { ...options, reach: undefined };
|
|
60
|
+
}
|
|
61
|
+
|
|
34
62
|
// Normalize screen name for directory
|
|
35
63
|
const screenName = this.normalizeScreenName(options.name);
|
|
64
|
+
// Always use qa/screens/ regardless of platform (1-platform model — no qa/mobile/ tree)
|
|
36
65
|
const screenDir = path.join(this.screensDir, screenName);
|
|
37
66
|
|
|
38
|
-
// Derive filename: --feature flag creates "<screen>-<feature>" name
|
|
67
|
+
// Derive filename: --feature flag creates "<screen>-<feature>" name.
|
|
68
|
+
// For mobile, `path` is the app entry (pkg/Activity), NOT a URL — never derive
|
|
69
|
+
// the filename from it; use the screen name instead.
|
|
39
70
|
let filename: string;
|
|
40
71
|
if (options.feature) {
|
|
41
72
|
filename = `${screenName}-${this.normalizeScreenName(options.feature)}`;
|
|
42
|
-
} else if (options.path) {
|
|
73
|
+
} else if (options.path && !isMobile) {
|
|
43
74
|
filename = this.extractFilenameFromPath(options.path);
|
|
44
75
|
} else {
|
|
45
76
|
filename = screenName;
|
|
@@ -92,18 +123,25 @@ export class ScreenManager {
|
|
|
92
123
|
const featureContent = this.generateFeatureTemplate(options, filename);
|
|
93
124
|
fs.writeFileSync(featurePath, featureContent, 'utf-8');
|
|
94
125
|
|
|
95
|
-
// Generate empty selector YAML
|
|
126
|
+
// Generate empty selector YAML. Web gets a `page` entry (the route); mobile
|
|
127
|
+
// selectors come from Appium MCP at run-test, so it's just a header comment.
|
|
96
128
|
if (!fs.existsSync(selectorPath)) {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
129
|
+
const selectorLines = isMobile
|
|
130
|
+
? [
|
|
131
|
+
`# ${options.name} Screen Selectors (mobile)`,
|
|
132
|
+
`# Generated at /sungen:run-test via Appium MCP — accessibility-id preferred.`,
|
|
133
|
+
``,
|
|
134
|
+
]
|
|
135
|
+
: [
|
|
136
|
+
`# ${options.name} Screen Selectors`,
|
|
137
|
+
`# Fill via AI or manually`,
|
|
138
|
+
``,
|
|
139
|
+
`${screenName}:`,
|
|
140
|
+
` type: 'page'`,
|
|
141
|
+
` value: '${options.path || `/${screenName}`}'`,
|
|
142
|
+
``,
|
|
143
|
+
];
|
|
144
|
+
fs.writeFileSync(selectorPath, selectorLines.join('\n'), 'utf-8');
|
|
107
145
|
}
|
|
108
146
|
|
|
109
147
|
// Generate empty test-data YAML
|
|
@@ -186,9 +224,9 @@ export class ScreenManager {
|
|
|
186
224
|
].join('\n'), 'utf-8');
|
|
187
225
|
}
|
|
188
226
|
|
|
189
|
-
// Auto-screenshot: capture page only when --capture is set (requires --path)
|
|
227
|
+
// Auto-screenshot: capture page only when --capture is set and not mobile (requires --path)
|
|
190
228
|
// Use filename (not screenName) so each feature gets its own screenshots
|
|
191
|
-
if (options.capture && options.path) {
|
|
229
|
+
if (options.capture && options.path && !isMobile) {
|
|
192
230
|
await this.captureScreenshot(options.path, requirementsUiDir, filename);
|
|
193
231
|
}
|
|
194
232
|
|
|
@@ -204,12 +242,21 @@ export class ScreenManager {
|
|
|
204
242
|
}
|
|
205
243
|
console.log('');
|
|
206
244
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
245
|
+
if (isMobile) {
|
|
246
|
+
console.log('Next steps:');
|
|
247
|
+
console.log(` 1. Fill requirements/spec.md with screen spec (elements, actions, business rules)`);
|
|
248
|
+
console.log(` Use /sungen:add-screen with sungen-capture-mobile to scan via Appium MCP`);
|
|
249
|
+
console.log(` 2. Generate test cases: /sungen:create-test ${screenName}`);
|
|
250
|
+
console.log(` 3. Compile: sungen generate --screen ${screenName} --framework appium`);
|
|
251
|
+
console.log(` 4. Run: npm run test:mobile\n`);
|
|
252
|
+
} else {
|
|
253
|
+
console.log('Next steps:');
|
|
254
|
+
console.log(` 1. Fill requirements/spec.md with screen spec (fields, validation, business rules)`);
|
|
255
|
+
console.log(` Optionally add UI designs to requirements/ui/ (screenshots, mockups)`);
|
|
256
|
+
console.log(` 2. Generate test cases: /sungen:create-test ${screenName} (or /sungen-create-test)`);
|
|
257
|
+
console.log(` 3. Compile: sungen generate --screen ${screenName}`);
|
|
258
|
+
console.log(` 4. Run: npx playwright test\n`);
|
|
259
|
+
}
|
|
213
260
|
}
|
|
214
261
|
|
|
215
262
|
/**
|
|
@@ -291,6 +338,57 @@ export class ScreenManager {
|
|
|
291
338
|
}
|
|
292
339
|
|
|
293
340
|
private generateSpecTemplate(options: ScreenOptions, screenName: string): string {
|
|
341
|
+
const caps = readCapabilities(this.cwd);
|
|
342
|
+
const isMobile = caps.platform === 'mobile';
|
|
343
|
+
|
|
344
|
+
if (isMobile) {
|
|
345
|
+
const appEntry = options.path || this.readWdioAppEntry() || '<appPackage>';
|
|
346
|
+
return `# ${options.name} Screen Specification
|
|
347
|
+
|
|
348
|
+
## Overview
|
|
349
|
+
- **App Entry:** ${appEntry}
|
|
350
|
+
- **Platform:** mobile (Android + iOS)
|
|
351
|
+
- **Auth Required:** no
|
|
352
|
+
- **Reach:** ${options.reach || '<!-- nav recipe: how to reach this screen from app launch, e.g. "tap [My Page] tab > tap [Settings] row" -->'}
|
|
353
|
+
|
|
354
|
+
## Sections
|
|
355
|
+
|
|
356
|
+
### Section: [Section Name]
|
|
357
|
+
- **Type:** list | card | form | modal | navigation | tab-bar
|
|
358
|
+
- **Description:** [Brief description of this section]
|
|
359
|
+
|
|
360
|
+
#### Elements
|
|
361
|
+
| Element | Type | Identifier | Notes |
|
|
362
|
+
|---------|------|------------|-------|
|
|
363
|
+
| [Element Name] | button | accessibility-id | [tap target, e.g., submits form] |
|
|
364
|
+
|
|
365
|
+
#### Actions
|
|
366
|
+
| Action | Element | Behavior |
|
|
367
|
+
|--------|---------|----------|
|
|
368
|
+
| [Action Name] | button | [What happens on tap] |
|
|
369
|
+
|
|
370
|
+
#### States
|
|
371
|
+
| State | Condition | Visual |
|
|
372
|
+
|-------|-----------|--------|
|
|
373
|
+
| Default | Screen load | [Default appearance] |
|
|
374
|
+
| Loading | After tap | [Loading indicator] |
|
|
375
|
+
| Error | Action fails | [Error appearance] |
|
|
376
|
+
| Success | Action complete | [Success behavior] |
|
|
377
|
+
|
|
378
|
+
## Business Rules
|
|
379
|
+
<!-- Rules that affect test logic: limits, permissions, conditions -->
|
|
380
|
+
- [Rule 1]
|
|
381
|
+
|
|
382
|
+
## Accessibility
|
|
383
|
+
<!-- Accessibility IDs, content-desc, aria-labels -->
|
|
384
|
+
- Accessibility IDs: [element: id]
|
|
385
|
+
|
|
386
|
+
## Notes
|
|
387
|
+
<!-- Edge cases, known issues, environment-specific behavior -->
|
|
388
|
+
- [Note 1]
|
|
389
|
+
`;
|
|
390
|
+
}
|
|
391
|
+
|
|
294
392
|
const pagePath = options.path || `/${screenName}`;
|
|
295
393
|
return `# ${options.name} Screen Specification
|
|
296
394
|
|
|
@@ -348,9 +446,48 @@ export class ScreenManager {
|
|
|
348
446
|
|
|
349
447
|
private generateFeatureTemplate(options: ScreenOptions, filename: string): string {
|
|
350
448
|
const screenName = this.normalizeScreenName(options.name);
|
|
351
|
-
const featurePath = options.path || `/${screenName}`;
|
|
352
449
|
const description = options.description || `interact with the ${options.name} screen`;
|
|
353
450
|
|
|
451
|
+
const caps = readCapabilities(this.cwd);
|
|
452
|
+
const isMobile = caps.platform === 'mobile';
|
|
453
|
+
|
|
454
|
+
if (isMobile) {
|
|
455
|
+
// Determine platform tag: --platform android/ios emits a per-OS tag for OS-specific screens.
|
|
456
|
+
// Default (no --platform) = cross-platform, no OS-specific tag.
|
|
457
|
+
const platformTag =
|
|
458
|
+
options.platform === 'android' ? '@platform:android'
|
|
459
|
+
: options.platform === 'ios' ? '@platform:ios'
|
|
460
|
+
: '@platform:mobile';
|
|
461
|
+
|
|
462
|
+
// App entry: explicit --path → wdio.conf.ts default → placeholder.
|
|
463
|
+
const appEntry = options.path || this.readWdioAppEntry() || '<appPackage>';
|
|
464
|
+
|
|
465
|
+
// Navigation recipe (Background block)
|
|
466
|
+
const background = this.buildMobileBackground(options.reach);
|
|
467
|
+
|
|
468
|
+
return `${platformTag}
|
|
469
|
+
Feature: ${options.name} Screen
|
|
470
|
+
|
|
471
|
+
As a user
|
|
472
|
+
I want to ${description}
|
|
473
|
+
So that I can accomplish my tasks
|
|
474
|
+
Path: ${appEntry}
|
|
475
|
+
|
|
476
|
+
# Mobile (Appium) — selectors generated at /sungen:run-test via Appium MCP.
|
|
477
|
+
# Use accessibility-id selectors; assert text content with \`header with {{var}}\`
|
|
478
|
+
# (compiles to content-desc/label via toHaveAttribute). See sungen-mobile-gestures
|
|
479
|
+
# for swipe/long-press/scroll steps.
|
|
480
|
+
|
|
481
|
+
${background}
|
|
482
|
+
@high
|
|
483
|
+
Scenario: Sample scenario for ${options.name}
|
|
484
|
+
When User tap [element]
|
|
485
|
+
Then User see [result] header with {{result_text}}
|
|
486
|
+
`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Web feature stub
|
|
490
|
+
const featurePath = options.path || `/${screenName}`;
|
|
354
491
|
return `Feature: ${options.name} Screen
|
|
355
492
|
|
|
356
493
|
As a user
|
|
@@ -367,4 +504,82 @@ export class ScreenManager {
|
|
|
367
504
|
Then User see [result] text with {{success}}
|
|
368
505
|
`;
|
|
369
506
|
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Read the default app entry (`<appPackage>/<appActivity>`) from the project's wdio.conf.ts so a
|
|
510
|
+
* mobile `add-screen` without `--path` scaffolds a real, runnable `Path:` matching the configured
|
|
511
|
+
* app instead of a `<placeholder>`. Supports both the env-default form
|
|
512
|
+
* (`APP_PACKAGE = process.env.APP_PACKAGE || '…'`) and the literal capability form
|
|
513
|
+
* (`'appium:appPackage': '…'`). Returns null when wdio.conf.ts is absent or unparseable.
|
|
514
|
+
*/
|
|
515
|
+
private readWdioAppEntry(): string | null {
|
|
516
|
+
const wdioPath = path.join(this.cwd, 'wdio.conf.ts');
|
|
517
|
+
if (!fs.existsSync(wdioPath)) return null;
|
|
518
|
+
let src: string;
|
|
519
|
+
try {
|
|
520
|
+
src = fs.readFileSync(wdioPath, 'utf-8');
|
|
521
|
+
} catch {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
const pick = (re: RegExp): string | undefined => src.match(re)?.[1]?.trim();
|
|
525
|
+
const pkg =
|
|
526
|
+
pick(/APP_PACKAGE\s*=\s*process\.env\.APP_PACKAGE\s*\|\|\s*['"]([^'"]+)['"]/) ??
|
|
527
|
+
pick(/['"]appium:appPackage['"]\s*:\s*['"]([^'"]+)['"]/);
|
|
528
|
+
if (!pkg) return null;
|
|
529
|
+
const activity =
|
|
530
|
+
pick(/APP_ACTIVITY\s*=\s*process\.env\.APP_ACTIVITY\s*\|\|\s*['"]([^'"]+)['"]/) ??
|
|
531
|
+
pick(/['"]appium:appActivity['"]\s*:\s*['"]([^'"]+)['"]/);
|
|
532
|
+
return activity ? `${pkg}/${activity}` : pkg;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Build the Background block for a mobile screen — the navigation recipe (in-app web-path
|
|
537
|
+
* analog). Line 1 is the `Given User is on [Home] screen` launcher/landing anchor; each step
|
|
538
|
+
* from `--reach` (a `>`-separated list) becomes a `When/And User <step>` nav line, taking the
|
|
539
|
+
* app from launch to THIS screen. capture-mobile replays these to land on the target screen
|
|
540
|
+
* before scanning. With no recipe, emit a guided stub (anchor + commented placeholder).
|
|
541
|
+
*/
|
|
542
|
+
private buildMobileBackground(reach?: string): string {
|
|
543
|
+
// Normalize one nav segment to a bare step body ("tap [My Page] tab"): drop any leading
|
|
544
|
+
// Gherkin keyword and a leading "User " so we can re-emit "When/And User <body>" cleanly.
|
|
545
|
+
const normalizeStep = (raw: string): string =>
|
|
546
|
+
raw
|
|
547
|
+
.trim()
|
|
548
|
+
.replace(/^(given|when|and|then|but)\s+/i, '')
|
|
549
|
+
.replace(/^user\s+/i, '')
|
|
550
|
+
.trim();
|
|
551
|
+
|
|
552
|
+
const steps = (reach || '')
|
|
553
|
+
.split('>')
|
|
554
|
+
.map(normalizeStep)
|
|
555
|
+
.filter(Boolean);
|
|
556
|
+
|
|
557
|
+
if (steps.length === 0) {
|
|
558
|
+
// No recipe → guided stub. Keep the anchor (correct as-is when this IS the launcher
|
|
559
|
+
// screen); the comments tell create-test / capture-mobile how to fill the nav steps.
|
|
560
|
+
return [
|
|
561
|
+
' Background:',
|
|
562
|
+
' # Navigation recipe — how to reach THIS screen from app launch (the in-app',
|
|
563
|
+
' # web-path analog). Line 1 is the launcher/landing screen the app opens on;',
|
|
564
|
+
' # add a `When/And User tap [...]` per hop to reach this screen. capture-mobile',
|
|
565
|
+
' # replays these so Appium lands here (not the launcher) before scanning. Leave',
|
|
566
|
+
' # only the anchor if this IS the launcher screen. Prefill with',
|
|
567
|
+
' # --reach "tap [Tab] > tap [Row]" at add-screen time.',
|
|
568
|
+
' Given User is on [Home] screen',
|
|
569
|
+
' # When User tap [<tab or button that leads to this screen>]',
|
|
570
|
+
'',
|
|
571
|
+
].join('\n');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const navLines = steps.map(
|
|
575
|
+
(body, i) => ` ${i === 0 ? 'When' : 'And'} User ${body}`,
|
|
576
|
+
);
|
|
577
|
+
return [
|
|
578
|
+
' Background:',
|
|
579
|
+
' # Navigation recipe — replayed by capture-mobile to land on this screen before scanning.',
|
|
580
|
+
' Given User is on [Home] screen',
|
|
581
|
+
...navLines,
|
|
582
|
+
'',
|
|
583
|
+
].join('\n');
|
|
584
|
+
}
|
|
370
585
|
}
|