@sun-asterisk/sungen 3.2.2-beta.11 → 3.2.2-beta.14
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/cli/commands/journey.d.ts.map +1 -1
- package/dist/cli/commands/journey.js +19 -1
- package/dist/cli/commands/journey.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 +20 -0
- package/dist/harness/journey.d.ts.map +1 -1
- package/dist/harness/journey.js +97 -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-agent-reviewer.md +12 -0
- 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 +23 -2
- 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 +8 -1
- 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 +11 -2
- 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 +8 -1
- 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/cli/commands/journey.ts +15 -2
- 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 +110 -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-agent-reviewer.md +12 -0
- 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 +23 -2
- 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 +8 -1
- 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 +11 -2
- 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 +8 -1
- 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
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
writeCsv,
|
|
22
22
|
} from '../../exporters/csv-exporter';
|
|
23
23
|
import { renderXlsxMultiSheet, writeXlsx, buildApiDetailRows, addApiDetailSheet } from '../../exporters/xlsx-exporter';
|
|
24
|
+
import { resolvePlatformAppId } from '../../exporters/mobile-app-id';
|
|
24
25
|
import { EnvironmentInfo, PreflightCheck, ScreenSummary, TestCaseRow, ApiCatalogEntry } from '../../exporters/types';
|
|
25
26
|
|
|
26
27
|
const COLOR = {
|
|
@@ -394,6 +395,40 @@ function getEnvironment(cwd: string): EnvironmentInfo {
|
|
|
394
395
|
return { baseURL, projectName, executor };
|
|
395
396
|
}
|
|
396
397
|
|
|
398
|
+
/**
|
|
399
|
+
* True when this is a mobile project (`platform: mobile` in qa/capabilities.yaml).
|
|
400
|
+
* Mobile results are written PER-OS (`<feature>-test-result.android|ios.json`) —
|
|
401
|
+
* android/ios are separate platform runs, not locales of one run — so delivery
|
|
402
|
+
* emits one csv+xlsx per OS and skips the (empty/misleading) base aggregate.
|
|
403
|
+
*/
|
|
404
|
+
function isMobileProject(cwd: string): boolean {
|
|
405
|
+
try {
|
|
406
|
+
const p = path.join(cwd, 'qa', 'capabilities.yaml');
|
|
407
|
+
if (!fs.existsSync(p)) return false;
|
|
408
|
+
const parsed = parseYaml(fs.readFileSync(p, 'utf-8')) as { platform?: string } | null;
|
|
409
|
+
return parsed?.platform === 'mobile';
|
|
410
|
+
} catch {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Remove a stale base (no-suffix) deliverable pair for a feature. Used in mobile
|
|
417
|
+
* per-OS mode where the base `<feature>-testcases.{csv,xlsx}` is not produced —
|
|
418
|
+
* deletes leftovers from a previous (pre-per-OS) delivery so the folder only
|
|
419
|
+
* holds the per-OS files.
|
|
420
|
+
*/
|
|
421
|
+
function removeBaseDeliverables(cwd: string, featureBaseName: string): void {
|
|
422
|
+
const dir = path.join(cwd, 'qa', 'deliverables');
|
|
423
|
+
for (const ext of ['csv', 'xlsx']) {
|
|
424
|
+
const f = path.join(dir, `${featureBaseName}-testcases.${ext}`);
|
|
425
|
+
if (fs.existsSync(f)) {
|
|
426
|
+
fs.rmSync(f);
|
|
427
|
+
log(` ${COLOR.gray}removed stale base deliverable ${path.relative(cwd, f)} (mobile → per-OS files)${COLOR.reset}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
397
432
|
// ----------------------------------------------------------------------------
|
|
398
433
|
// Per-screen export
|
|
399
434
|
// ----------------------------------------------------------------------------
|
|
@@ -440,10 +475,15 @@ async function exportTarget(
|
|
|
440
475
|
// one CSV per locale (CSV has no sheet concept).
|
|
441
476
|
// <2 variants → single-sheet XLSX with the canonical 'Testcases' sheet.
|
|
442
477
|
if (explicitEnv) {
|
|
478
|
+
// Mobile: a stale base (no-suffix) pair from a pre-per-OS run is misleading
|
|
479
|
+
// sitting next to the env-suffixed file — drop it.
|
|
480
|
+
if (isMobileProject(cwd)) removeBaseDeliverables(cwd, target.featureBaseName);
|
|
443
481
|
const rows = buildTestCaseRows({
|
|
444
482
|
screen: label,
|
|
445
483
|
featureName: feature.featureName,
|
|
446
|
-
|
|
484
|
+
// OS-precise env column: a mobile dual-id Path resolves to just the
|
|
485
|
+
// android package / ios bundle id for SUNGEN_ENV=android|ios.
|
|
486
|
+
featurePath: resolvePlatformAppId(featurePath, explicitEnv),
|
|
447
487
|
merged,
|
|
448
488
|
testData: loadTestData(testDataFile),
|
|
449
489
|
results: resolveResultsPath(cwd, target) ? loadPlaywrightReport(resolveResultsPath(cwd, target)!) : null,
|
|
@@ -470,6 +510,50 @@ async function exportTarget(
|
|
|
470
510
|
}
|
|
471
511
|
|
|
472
512
|
const variants = discoverLocaleVariants(cwd, target);
|
|
513
|
+
|
|
514
|
+
// Mobile: emit ONE deliverable per OS (csv + xlsx), each from its own
|
|
515
|
+
// `<feature>-test-result.<os>.json`. Android/iOS are SEPARATE platform runs,
|
|
516
|
+
// not locales of one run, so a single aggregated base workbook is empty &
|
|
517
|
+
// misleading — produce per-OS files and skip the base (issue: per-OS delivery).
|
|
518
|
+
if (isMobileProject(cwd)) {
|
|
519
|
+
// OS runs = variants whose first dot-segment is android|ios (incl. env-suffixed
|
|
520
|
+
// android.staging / ios.ja). A bare locale variant is NOT an OS run and stays on
|
|
521
|
+
// the standard aggregation path below.
|
|
522
|
+
const osVariants = variants.filter((v) => !!v.resultsPath && /^(android|ios)(\.|$)/.test(v.locale));
|
|
523
|
+
if (osVariants.length > 0) {
|
|
524
|
+
removeBaseDeliverables(cwd, target.featureBaseName); // drop any stale no-suffix pair
|
|
525
|
+
let mobileSummary: ScreenSummary | null = null;
|
|
526
|
+
for (const variant of osVariants) {
|
|
527
|
+
const variantRows = buildTestCaseRows({
|
|
528
|
+
screen: label,
|
|
529
|
+
featureName: feature.featureName,
|
|
530
|
+
featurePath: resolvePlatformAppId(featurePath, variant.locale), // OS-precise app id
|
|
531
|
+
merged,
|
|
532
|
+
testData: loadTestData(testDataFile, variant.locale || null),
|
|
533
|
+
results: variant.resultsPath ? loadPlaywrightReport(variant.resultsPath) : null,
|
|
534
|
+
env,
|
|
535
|
+
selectorKeyMap,
|
|
536
|
+
});
|
|
537
|
+
const csv = renderCsv(buildSummary(label, variantRows, ''), variantRows, specLink);
|
|
538
|
+
const csvPath = writeCsv(cwd, target.featureBaseName, csv, variant.locale);
|
|
539
|
+
const autoRows = variantRows.filter((r) => r.testcaseType !== 'Manual');
|
|
540
|
+
const manualRows = variantRows.filter((r) => r.testcaseType === 'Manual');
|
|
541
|
+
const wb = renderXlsxMultiSheet([
|
|
542
|
+
{ sheetName: 'Auto', summary: buildSummary(label, autoRows, ''), rows: autoRows, specLink },
|
|
543
|
+
{ sheetName: 'Manual', summary: buildSummary(label, manualRows, ''), rows: manualRows, specLink },
|
|
544
|
+
]);
|
|
545
|
+
await writeXlsx(cwd, target.featureBaseName, wb, variant.locale);
|
|
546
|
+
const s = buildSummary(label, variantRows, path.relative(cwd, csvPath));
|
|
547
|
+
// Prefer the android line for the single summary-table row (deterministic);
|
|
548
|
+
// startsWith covers env-suffixed android.staging etc.
|
|
549
|
+
if (!mobileSummary || variant.locale.startsWith('android')) mobileSummary = s;
|
|
550
|
+
}
|
|
551
|
+
return mobileSummary;
|
|
552
|
+
}
|
|
553
|
+
// Mobile but no per-OS results yet → fall through to the base export below
|
|
554
|
+
// so the test CASES still deliver (execution columns empty/pending).
|
|
555
|
+
}
|
|
556
|
+
|
|
473
557
|
let primarySummary: ScreenSummary | null = null;
|
|
474
558
|
let primaryCsvPath = '';
|
|
475
559
|
// XLSX is split by automation type: an "Auto" sheet (automatable TCs, results differ per
|
|
@@ -487,7 +571,10 @@ async function exportTarget(
|
|
|
487
571
|
const variantRows = buildTestCaseRows({
|
|
488
572
|
screen: label,
|
|
489
573
|
featureName: feature.featureName,
|
|
490
|
-
|
|
574
|
+
// No-op for web (URL Path); for a mobile fall-through (a no-suffix result
|
|
575
|
+
// json) this collapses the dual-id to the android package so the combined
|
|
576
|
+
// dual-id never leaks into the env column.
|
|
577
|
+
featurePath: resolvePlatformAppId(featurePath, variant.locale),
|
|
491
578
|
merged,
|
|
492
579
|
testData: variantTestData,
|
|
493
580
|
results: variantResults,
|
|
@@ -219,7 +219,8 @@ export function registerGenerateCommand(program: Command): void {
|
|
|
219
219
|
for (const w of ddWarnings.slice(0, 20)) console.log(` ${w.scenario ? w.scenario + ': ' : ''}${w.message}`);
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
|
|
222
|
+
const nextStep = platform === 'mobile' ? 'npm run test:mobile' : 'npx playwright test --ui';
|
|
223
|
+
console.log(`Next step: ${nextStep}\n`);
|
|
223
224
|
} catch (error) {
|
|
224
225
|
console.error('Error:', error instanceof Error ? error.message : error);
|
|
225
226
|
process.exit(1);
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -4,11 +4,46 @@ export function registerInitCommand(program: Command): void {
|
|
|
4
4
|
program
|
|
5
5
|
.command('init [projectName]')
|
|
6
6
|
.description('Initialize Sungen project (optionally create and initialize in a new project folder)')
|
|
7
|
-
.
|
|
8
|
-
.
|
|
7
|
+
.option('--base-url <url>', 'Base URL for Playwright (web) tests (e.g. https://your-app.com). Required for web projects (omit only with --mobile)')
|
|
8
|
+
.option('--mobile', 'Mobile project (Appium/WebdriverIO): scaffold ONLY the mobile harness — no Playwright config, no Chromium download, no --base-url needed')
|
|
9
|
+
.option('--app-package <pkg>', 'Android appPackage for mobile (e.g. com.example.app). Used with --mobile')
|
|
10
|
+
.option('--app-activity <activity>', 'Android appActivity for mobile (e.g. com.example.app.MainActivity). OPTIONAL — omit to let Appium auto-resolve the launcher activity from --app-package; pass it only to pin a non-standard launcher. Used with --mobile')
|
|
11
|
+
.option('--app-apk <path>', 'Path to an .apk to install & run for mobile (alternative to package/activity). Used with --mobile')
|
|
12
|
+
.option('--android-udid <udid>', 'Default Android device/emulator UDID for mobile (default: emulator-5554)')
|
|
13
|
+
.option('--ios-bundle-id <id>', 'iOS bundle id for mobile (e.g. com.example.app.dev) — default app when MOBILE_PLATFORM=ios|both. Used with --mobile')
|
|
14
|
+
.option('--ios-device <name>', "iOS Simulator device name for mobile (default: 'iPhone 16'). Used with --mobile")
|
|
15
|
+
.option('--ios-version <ver>', "iOS Simulator platform version for mobile (default: '18.0'). Used with --mobile")
|
|
16
|
+
.action(async (projectName: string | undefined, options: {
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
mobile?: boolean;
|
|
19
|
+
appPackage?: string;
|
|
20
|
+
appActivity?: string;
|
|
21
|
+
appApk?: string;
|
|
22
|
+
androidUdid?: string;
|
|
23
|
+
iosBundleId?: string;
|
|
24
|
+
iosDevice?: string;
|
|
25
|
+
iosVersion?: string;
|
|
26
|
+
}) => {
|
|
9
27
|
try {
|
|
28
|
+
// `--mobile` = a mobile-only project: no web scaffolding, no base URL needed.
|
|
29
|
+
// Plain `init` = a web project and requires `--base-url`.
|
|
30
|
+
const mobile = !!options.mobile;
|
|
31
|
+
if (!mobile && !options.baseUrl) {
|
|
32
|
+
console.error('❌ --base-url is required for a web project. For a mobile project pass --mobile (no base URL needed).');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
10
35
|
const { ProjectInitializer } = require('../../orchestrator/project-initializer');
|
|
11
|
-
const initializer = new ProjectInitializer(options.baseUrl
|
|
36
|
+
const initializer = new ProjectInitializer(options.baseUrl || '', {
|
|
37
|
+
enabled: mobile,
|
|
38
|
+
mobileOnly: mobile,
|
|
39
|
+
appPackage: options.appPackage,
|
|
40
|
+
appActivity: options.appActivity,
|
|
41
|
+
appApk: options.appApk,
|
|
42
|
+
androidUdid: options.androidUdid,
|
|
43
|
+
iosBundleId: options.iosBundleId,
|
|
44
|
+
iosDevice: options.iosDevice,
|
|
45
|
+
iosVersion: options.iosVersion,
|
|
46
|
+
});
|
|
12
47
|
await initializer.initialize(projectName);
|
|
13
48
|
} catch (error) {
|
|
14
49
|
console.error('❌ Init failed:', error);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import * as fs from 'fs';
|
|
4
|
-
import { runJourney, waive, signoff, renderJourneyBoard } from '../../harness/journey';
|
|
4
|
+
import { runJourney, waive, signoff, renderJourneyBoard, rollupJourney, renderRollupBoard } from '../../harness/journey';
|
|
5
5
|
import { reportSlug } from '../../harness/unit-paths';
|
|
6
6
|
|
|
7
7
|
function findScreenDir(name: string): string | null {
|
|
@@ -19,6 +19,7 @@ export function registerJourneyCommand(program: Command): void {
|
|
|
19
19
|
.command('journey')
|
|
20
20
|
.description('Durable "you are here" board (#381): obligations + what-to-review + next, synthesised read-only from the audit report + ledger already on disk.')
|
|
21
21
|
.option('-s, --screen <name>', 'Screen / flow / api unit name')
|
|
22
|
+
.option('--all', 'Roll up every unit (screens + flows + api) into one board — the serial review surface for multi-unit fan-out')
|
|
22
23
|
.option('--waive <obligation>', 'Waive an obligation (e.g. OB-coverage) — requires --reason')
|
|
23
24
|
.option('--reason <text>', 'The reason a waived obligation is acceptable (mandatory with --waive)')
|
|
24
25
|
.option('--signoff', 'Sign off the review queue — the single human gate (allowed only when every other obligation is satisfied/waived)')
|
|
@@ -26,8 +27,20 @@ export function registerJourneyCommand(program: Command): void {
|
|
|
26
27
|
.option('--json', 'Output the raw JSON report')
|
|
27
28
|
.action((options) => {
|
|
28
29
|
try {
|
|
30
|
+
if (options.all) {
|
|
31
|
+
const rows = rollupJourney(process.cwd());
|
|
32
|
+
const board = renderRollupBoard(rows);
|
|
33
|
+
const outDir = path.join(process.cwd(), '.sungen', 'journey');
|
|
34
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
35
|
+
fs.writeFileSync(path.join(outDir, '_rollup.json'), JSON.stringify(rows, null, 2), 'utf-8');
|
|
36
|
+
fs.writeFileSync(path.join(outDir, '_rollup.board.md'), board, 'utf-8');
|
|
37
|
+
if (options.json) console.log(JSON.stringify(rows, null, 2));
|
|
38
|
+
else { console.log(''); console.log(board); console.log(` Board: ${path.relative(process.cwd(), path.join(outDir, '_rollup.board.md'))}`); console.log(''); }
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
const name = options.screen;
|
|
30
|
-
if (!name) throw new Error('Provide --screen <name>');
|
|
43
|
+
if (!name) throw new Error('Provide --screen <name> (or --all for the roll-up)');
|
|
31
44
|
if (!findScreenDir(name)) throw new Error(`Not found: qa/screens/${name}, qa/flows/${name}, or qa/api/${name}`);
|
|
32
45
|
|
|
33
46
|
const report = options.waive
|
|
@@ -22,6 +22,7 @@ import { loadSelectorKeyMap } from '../exporters/selector-key-resolver';
|
|
|
22
22
|
import { loadPlaywrightReport } from '../exporters/playwright-report-parser';
|
|
23
23
|
import { mergeFeatureAndSpec } from '../exporters/scenario-merger';
|
|
24
24
|
import { buildScreenSnapshot } from '../exporters/json-exporter';
|
|
25
|
+
import { resolvePlatformAppId } from '../exporters/mobile-app-id';
|
|
25
26
|
import { getPackageVersion } from '../exporters/package-info';
|
|
26
27
|
import { EnvironmentInfo } from '../exporters/types';
|
|
27
28
|
import {
|
|
@@ -167,6 +168,12 @@ function buildOneFeature(
|
|
|
167
168
|
// Discover locale variants by scanning <feature>-test-result*.json files.
|
|
168
169
|
// Always includes base ('') as the first entry, even if its results file
|
|
169
170
|
// is missing — UI still needs to render the feature.
|
|
171
|
+
// NOTE (mobile): for a `platform: mobile` project the "locale" variants are
|
|
172
|
+
// actually the per-OS runs (android / ios) — the dashboard reuses the locale
|
|
173
|
+
// switcher to surface them as tabs. The env column per tab is OS-precise (see
|
|
174
|
+
// resolvePlatformAppId below), but the top-level featurePath (base variant)
|
|
175
|
+
// defaults to the Android id. Delivery has a dedicated per-OS path; aligning the
|
|
176
|
+
// dashboard onto a distinct "platform" axis is a possible follow-up.
|
|
170
177
|
const variants = discoverLocaleVariants(genBase, featureBaseName);
|
|
171
178
|
|
|
172
179
|
// Build per-locale ScreenSnapshot, then collect into FeatureSnapshot.locales[].
|
|
@@ -185,7 +192,9 @@ function buildOneFeature(
|
|
|
185
192
|
screen: featureBaseName,
|
|
186
193
|
isFlow: target.isFlow,
|
|
187
194
|
featureName: feature.featureName,
|
|
188
|
-
|
|
195
|
+
// OS-precise env column: a mobile dual-id Path resolves to just the android
|
|
196
|
+
// package / ios bundle id for the android|ios result variant.
|
|
197
|
+
featurePath: resolvePlatformAppId(featurePath, variant.locale),
|
|
189
198
|
specLink,
|
|
190
199
|
merged,
|
|
191
200
|
testData,
|
|
@@ -202,7 +202,9 @@ export function buildSummary(screen: string, rows: TestCaseRow[], outputFile: st
|
|
|
202
202
|
function formatEnv(baseURL: string, featurePath: string | undefined, projectName: string): string {
|
|
203
203
|
const base = (baseURL || '').replace(/\/+$/, '');
|
|
204
204
|
const p = (featurePath || '').replace(/^\/+/, '');
|
|
205
|
-
|
|
205
|
+
// Join with '/' only when there is a baseURL (web). For mobile baseURL is empty
|
|
206
|
+
// and `p` is an app id (e.g. com.kngroup.media.converter) — emit it bare, no '/'.
|
|
207
|
+
const url = base && p ? `${base}/${p}` : (p || base);
|
|
206
208
|
return `${url} (${projectName})`;
|
|
207
209
|
}
|
|
208
210
|
|
|
@@ -165,7 +165,9 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
|
|
|
165
165
|
function formatTestEnvironment(baseURL: string, featurePath: string | undefined, projectName: string): string {
|
|
166
166
|
const base = (baseURL || '').replace(/\/+$/, '');
|
|
167
167
|
const p = (featurePath || '').replace(/^\/+/, '');
|
|
168
|
-
|
|
168
|
+
// Join with '/' only when there is a baseURL (web). For mobile baseURL is empty
|
|
169
|
+
// and `p` is an app id (e.g. com.kngroup.media.converter) — emit it bare, no '/'.
|
|
170
|
+
const url = base && p ? `${base}/${p}` : (p || base);
|
|
169
171
|
return `${url} (${projectName})`;
|
|
170
172
|
}
|
|
171
173
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the OS-specific app id from a feature `Path:` for the delivery /
|
|
3
|
+
* dashboard "Test Environment" column.
|
|
4
|
+
*
|
|
5
|
+
* Mobile features carry a DUAL-ID Path: `<androidPkg>[/<activity>] | <iosBundleId>`
|
|
6
|
+
* (e.g. `com.kngroup.media.converter | com.kngroup.media.converter.dev`). The
|
|
7
|
+
* deliverable's environment column must show ONLY the id for the OS the results
|
|
8
|
+
* came from — Android → the package (launch activity stripped), iOS → the bundle
|
|
9
|
+
* id — never the combined dual-id.
|
|
10
|
+
*
|
|
11
|
+
* Web features carry a URL Path (no `|`) and are returned unchanged.
|
|
12
|
+
*
|
|
13
|
+
* @param featurePath the feature `Path:` value (dual-id for mobile, URL for web)
|
|
14
|
+
* @param platform the OS/result axis the row belongs to. The OS is the FIRST
|
|
15
|
+
* dot-segment, so env-suffixed result variants resolve too —
|
|
16
|
+
* `'ios'`, `'ios.staging'`, `'ios.ja'` all select the bundle id;
|
|
17
|
+
* anything else (`'android'`, `'android.staging'`, a bare locale,
|
|
18
|
+
* base) falls back to the Android package.
|
|
19
|
+
* @returns the resolved single id, or `featurePath` unchanged when it is not a dual-id.
|
|
20
|
+
*/
|
|
21
|
+
export function resolvePlatformAppId(
|
|
22
|
+
featurePath: string | undefined,
|
|
23
|
+
platform: string | undefined,
|
|
24
|
+
): string | undefined {
|
|
25
|
+
if (!featurePath || !featurePath.includes('|')) return featurePath;
|
|
26
|
+
const [androidPart = '', iosPart = ''] = featurePath.split('|').map((s) => s.trim());
|
|
27
|
+
const androidId = androidPart.split('/')[0].trim(); // package only (drop /MainActivity)
|
|
28
|
+
const iosId = iosPart.trim();
|
|
29
|
+
// OS = first dot-segment so `ios.staging` / `ios.ja` (env-suffixed result files
|
|
30
|
+
// <name>-test-result.<os>[.<env>].json) still resolve to the iOS bundle id.
|
|
31
|
+
const os = (platform || '').toLowerCase().split('.')[0];
|
|
32
|
+
if (os === 'ios') return iosId || androidId;
|
|
33
|
+
return androidId || iosId;
|
|
34
|
+
}
|
|
@@ -25,7 +25,9 @@ function extractTestBlock(content: string, startIdx: number): {
|
|
|
25
25
|
// The options object uses `\{.*?\}` (non-greedy, not `[^}]*`) so a tag value containing a
|
|
26
26
|
// `}` — e.g. `@query:q(p={{var}})` — doesn't truncate the match (issue #271). The header is
|
|
27
27
|
// single-line, so `.*?` anchored on `}, async` is safe.
|
|
28
|
-
|
|
28
|
+
// Accept both Playwright `test(...)` and mobile/Appium mocha `it(...)` (issue #391). The leading
|
|
29
|
+
// `\b` prevents `it(` from matching the tail of a larger word like `submit(` / `edit(`.
|
|
30
|
+
const testRegex = /\b(?:test|it)\s*\(\s*(['"`])((?:(?!\1).)+)\1\s*,\s*(?:\{.*?\}\s*,\s*)?async\s*\([^)]*\)\s*=>\s*\{/g;
|
|
29
31
|
testRegex.lastIndex = startIdx;
|
|
30
32
|
const match = testRegex.exec(content);
|
|
31
33
|
if (!match) return null;
|
|
@@ -57,7 +59,9 @@ function extractTestBlock(content: string, startIdx: number): {
|
|
|
57
59
|
*/
|
|
58
60
|
function extractDescribeBlocks(content: string): Array<{ name: string; bodyStart: number; bodyEnd: number }> {
|
|
59
61
|
const result: Array<{ name: string; bodyStart: number; bodyEnd: number }> = [];
|
|
60
|
-
|
|
62
|
+
// Accept Playwright `test.describe(...)` / `test.describe.serial(...)` AND mobile/Appium mocha
|
|
63
|
+
// `describe(...)` (issue #391). The optional `test.` prefix keeps web specs matching unchanged.
|
|
64
|
+
const regex = /(?:test\.)?describe(?:\.serial)?\s*\(\s*['"]([^'"]+)['"]\s*,\s*\(\s*\)\s*=>\s*\{/g;
|
|
61
65
|
let match: RegExpExecArray | null;
|
|
62
66
|
while ((match = regex.exec(content)) !== null) {
|
|
63
67
|
const name = match[1];
|
|
@@ -23,11 +23,14 @@ export interface TestFileData {
|
|
|
23
23
|
featureFileName?: string; // Feature file name for TestDataLoader.load()
|
|
24
24
|
isParallel?: boolean; // @parallel tag: fresh page per test (opt-out from serial default)
|
|
25
25
|
flowMode?: boolean; // flow: cross-screen → longer per-test timeout (bug #243)
|
|
26
|
+
platform?: string; // mobile @platform:* value (mobile|android|ios) → `// sungen:platform=` marker for wdio per-OS routing
|
|
26
27
|
cleanup?: { overlay?: boolean; forms?: boolean; scroll?: boolean; storage?: boolean };
|
|
27
28
|
backgroundSteps?: Array<{ comment?: string; code: string }>; // Raw background steps for serial beforeAll
|
|
28
29
|
scenarios: string[];
|
|
29
30
|
authGroups?: AuthGroup[]; // Grouped by auth role for nested describes
|
|
30
31
|
singleAuthRole?: string; // Auth role when all scenarios share the same role
|
|
32
|
+
appPackage?: string; // Mobile (appium): Android package from the feature `Path:` → drives the per-scenario relaunch reset (beforeEach terminate+activate)
|
|
33
|
+
iosBundleId?: string; // Mobile (appium): iOS bundle id from a dual-id `Path: <pkg>/<activity> | <bundleId>`
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export interface ScenarioData {
|
package/src/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
if (driver.isIOS) {
|
|
2
|
-
// iOS (XCUITest) has no UiScrollable. Swipe
|
|
3
|
-
//
|
|
4
|
-
// it
|
|
2
|
+
// iOS (XCUITest) has no UiScrollable. Swipe up in bounded steps until the target is on screen (a
|
|
3
|
+
// below-fold element isn't in the XCUITest tree until scrolled). A settle `pause` between swipes
|
|
4
|
+
// paces the loop so it doesn't flood `execute/sync` (rapid back-to-back swipes are a common source
|
|
5
|
+
// of flakiness; issue #394). Bounded (cap 10) so a missing target can't loop forever. NOTE: this
|
|
6
|
+
// does NOT fix an app that crashes mid-scroll on its own — that's an app-stability issue, not codegen.
|
|
5
7
|
const __t = $({{> appium-selector-expr}});
|
|
6
|
-
for (let __i = 0; __i <
|
|
8
|
+
for (let __i = 0; __i < 10 && !(await __t.isDisplayed().catch(() => false)); __i++) {
|
|
7
9
|
await driver.execute('mobile: swipe', { direction: 'up' });
|
|
10
|
+
await driver.pause(400); // settle: let the app render between swipes (don't flood execute/sync)
|
|
8
11
|
}
|
|
9
12
|
} else {
|
|
10
13
|
{{#if (eq strategy 'accessibility-id')}}
|
package/src/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
-
{{!-- A switch/toggle flips state on tap (
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
{{!-- A switch/toggle flips state on tap. On Flutter (and many native layouts) the row's a11y node
|
|
2
|
+
spans the FULL width and merges the label with the switch, so a centre .click() lands on the
|
|
3
|
+
LABEL and does NOT flip the control — only the trailing visual switch toggles. So tap the
|
|
4
|
+
RIGHT portion (vertical centre) via getElementRect (W3C-standard → Android + iOS). Falls back to
|
|
5
|
+
a plain centre click when the rect is unavailable. Use `check`/`uncheck` to drive a specific
|
|
6
|
+
on/off state idempotently instead. --}}
|
|
7
|
+
await (async () => {
|
|
8
|
+
const e = await {{> locator}};
|
|
9
|
+
const r = await driver.getElementRect(e.elementId);
|
|
10
|
+
if (r && r.width) {
|
|
11
|
+
// Trailing switch: ~right edge, vertical centre. The inset clears the label/track padding while
|
|
12
|
+
// staying on the switch even on a full-width composite row.
|
|
13
|
+
const x = Math.round(r.x + r.width - Math.max(48, r.width * 0.08));
|
|
14
|
+
const y = Math.round(r.y + r.height / 2);
|
|
15
|
+
if (driver.isIOS) { await driver.execute('mobile: tap', { x, y }); }
|
|
16
|
+
else { await driver.execute('mobile: clickGesture', { x, y }); }
|
|
17
|
+
} else {
|
|
18
|
+
await e.click();
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
@@ -1 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// Mobile toggle state lives in the `checked` attribute (Android) or `value` "1"/"0" (iOS) — NOT
|
|
2
|
+
// `selected` (what toBeSelected() reads; always false for a Flutter Switch). The composite row node
|
|
3
|
+
// carries it, so the row selector resolves the state directly.
|
|
4
|
+
await expect({{> locator}}).toHaveAttribute(driver.isIOS ? 'value' : 'checked', driver.isIOS ? '1' : 'true');
|
|
@@ -1 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// Mobile toggle state lives in the `checked` attribute (Android) or `value` "1"/"0" (iOS) — NOT
|
|
2
|
+
// `selected` (what toBeSelected() reads; always false for a Flutter Switch). The composite row node
|
|
3
|
+
// carries it, so the row selector resolves the state directly.
|
|
4
|
+
await expect({{> locator}}).not.toHaveAttribute(driver.isIOS ? 'value' : 'checked', driver.isIOS ? '1' : 'true');
|
|
@@ -152,6 +152,9 @@ describe('{{featureName}}', () => {
|
|
|
152
152
|
const __APP_PKG__ = __resolveAppId();
|
|
153
153
|
try { await driver.terminateApp(__APP_PKG__); } catch { /* app may not be running yet */ }
|
|
154
154
|
await driver.activateApp(__APP_PKG__);
|
|
155
|
+
// Restore portrait so a prior scenario's `rotate to landscape` can't leak into this one — the
|
|
156
|
+
// app relaunch above resets in-app state, but device orientation is OS-level and persists.
|
|
157
|
+
try { await driver.setOrientation('PORTRAIT'); } catch { /* orientation control not supported */ }
|
|
155
158
|
// Settle after a cold (re)launch: a Flutter app first renders a BLANK accessibility skeleton
|
|
156
159
|
// (nested FrameLayout/View, no content-desc) for several seconds before the real tree appears.
|
|
157
160
|
// That skeleton is static, so a "two equal reads" check alone false-positives on it and we'd
|
|
@@ -173,8 +173,10 @@ function getEffectiveAuthRole(
|
|
|
173
173
|
* @manual at scenario level → skip that scenario
|
|
174
174
|
*/
|
|
175
175
|
function isManual(tags: string[]): boolean {
|
|
176
|
-
//
|
|
177
|
-
|
|
176
|
+
// Recognize bare `@manual` AND the reason-coded `@manual:Mx` convention (what create-test emits) —
|
|
177
|
+
// must match harness/parse.ts + capability-plan.ts, else an `@manual:Mx` scenario is wrongly
|
|
178
|
+
// GENERATED (and run) instead of skipped. @deferred:flow is owned by a flow → also skips here (H6).
|
|
179
|
+
return tags.some(tag => /^@manual\b/i.test(tag) || tag === '@deferred:flow');
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
/**
|
|
@@ -487,7 +489,15 @@ export class CodeGenerator {
|
|
|
487
489
|
let background: string | undefined;
|
|
488
490
|
let backgroundSteps: Array<{ comment?: string; code: string }> | undefined;
|
|
489
491
|
if (feature.background) {
|
|
490
|
-
|
|
492
|
+
// Mobile (appium) ALWAYS inlines Background via `backgroundSteps`: the test-file template emits a
|
|
493
|
+
// relaunch-per-scenario beforeEach and renders Background INSIDE it (it has no separate `background`
|
|
494
|
+
// beforeEach slot), and that per-scenario relaunch already gives @parallel its reset semantics.
|
|
495
|
+
// Routing a @parallel *mobile* feature to generateBeforeEach() computes the nav steps into
|
|
496
|
+
// `background`, which the mobile template then ignores → the Background nav (e.g. `tap [Settings]
|
|
497
|
+
// tab`) is silently dropped and every scenario runs on the launcher. So branch on isParallel only
|
|
498
|
+
// for web; for appium always use generateBackgroundSteps().
|
|
499
|
+
const isMobile = this.adapter.frameworkName === 'appium';
|
|
500
|
+
if (isParallel && !isMobile) {
|
|
491
501
|
background = await this.generateBeforeEach(feature.background);
|
|
492
502
|
} else {
|
|
493
503
|
backgroundSteps = await this.generateBackgroundSteps(feature.background);
|
|
@@ -597,6 +607,29 @@ export class CodeGenerator {
|
|
|
597
607
|
const cleanup = extractCleanupFlags(feature.tags || []);
|
|
598
608
|
const screenshotOnFailure = hasScreenshotOnFailure(feature.tags || []);
|
|
599
609
|
|
|
610
|
+
// Mobile: the @platform:* value (mobile|android|ios). Emitted as a `// sungen:platform=` marker
|
|
611
|
+
// so wdio.conf routes the spec per OS — android/ios = that OS only, mobile (default) = both.
|
|
612
|
+
// Only the appium adapter consumes it; web features have no @platform tag → undefined → no marker.
|
|
613
|
+
const platform = this.adapter.frameworkName === 'appium'
|
|
614
|
+
? (feature.tags || []).find(t => t.startsWith('@platform:'))?.split(':')[1]
|
|
615
|
+
: undefined;
|
|
616
|
+
|
|
617
|
+
// Mobile: parse the feature `Path:` (the app entry) into the app id(s) the per-scenario relaunch
|
|
618
|
+
// reset needs. Dual-id form: `<androidPkg>[/<activity>] | <iosBundleId>` — appPackage is the Android
|
|
619
|
+
// package (activity stripped, it isn't needed for terminateApp/activateApp); iosBundleId is the part
|
|
620
|
+
// after `|` when present. Without these the test-file template's `{{#if appPackage}}` stays false and
|
|
621
|
+
// the beforeEach (terminate+activate) reset is never emitted → nav scenarios cascade-fail.
|
|
622
|
+
let appPackage: string | undefined;
|
|
623
|
+
let iosBundleId: string | undefined;
|
|
624
|
+
if (this.adapter.frameworkName === 'appium' && feature.path) {
|
|
625
|
+
const [androidPart, iosPart] = feature.path.split('|').map(s => s.trim());
|
|
626
|
+
// Guard against a web-style URL Path (`/login`): only treat as a package when it isn't a route.
|
|
627
|
+
if (androidPart && !androidPart.startsWith('/')) {
|
|
628
|
+
appPackage = androidPart.split('/')[0].trim() || undefined;
|
|
629
|
+
}
|
|
630
|
+
if (iosPart) iosBundleId = iosPart;
|
|
631
|
+
}
|
|
632
|
+
|
|
600
633
|
// Use adapter to render the complete test file structure
|
|
601
634
|
return this.adapter.renderTestFile({
|
|
602
635
|
imports: '', // Not used in template as it's rendered separately
|
|
@@ -613,11 +646,14 @@ export class CodeGenerator {
|
|
|
613
646
|
featureFileName: featureName,
|
|
614
647
|
isParallel,
|
|
615
648
|
flowMode: isFlowFeature,
|
|
649
|
+
platform,
|
|
616
650
|
cleanup,
|
|
617
651
|
backgroundSteps,
|
|
618
652
|
scenarios: needsGrouping ? [] : scenarios,
|
|
619
653
|
authGroups: needsGrouping ? authGroups : undefined,
|
|
620
654
|
singleAuthRole,
|
|
655
|
+
appPackage,
|
|
656
|
+
iosBundleId,
|
|
621
657
|
});
|
|
622
658
|
}
|
|
623
659
|
|
|
@@ -21,9 +21,19 @@ export class TemplateEngine {
|
|
|
21
21
|
return text.split('\n').map(line => indentation + line).join('\n');
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
// Escape a raw string for embedding inside a single-quoted JS string literal. Every caller wraps
|
|
25
|
+
// the result in '…' (selector values, attribute values, scope), so a value containing a newline,
|
|
26
|
+
// backslash, or quote must be escaped or the generated spec is a syntax error (e.g. a Flutter
|
|
27
|
+
// composite content-desc like "Quick Convert\nSelect File\n…" used as an accessibility-id).
|
|
28
|
+
// Order matters: backslash first, then the rest.
|
|
24
29
|
Handlebars.registerHelper('escapeQuotes', function(text: string) {
|
|
25
30
|
if (text == null) return '';
|
|
26
|
-
return String(text)
|
|
31
|
+
return String(text)
|
|
32
|
+
.replace(/\\/g, '\\\\')
|
|
33
|
+
.replace(/'/g, "\\'")
|
|
34
|
+
.replace(/\n/g, '\\n')
|
|
35
|
+
.replace(/\r/g, '\\r')
|
|
36
|
+
.replace(/\t/g, '\\t');
|
|
27
37
|
});
|
|
28
38
|
|
|
29
39
|
Handlebars.registerHelper('escapeRegex', function(text: string) {
|
|
@@ -259,11 +269,14 @@ export class TemplateEngine {
|
|
|
259
269
|
screenName?: string;
|
|
260
270
|
featureFileName?: string;
|
|
261
271
|
isParallel?: boolean;
|
|
272
|
+
platform?: string;
|
|
262
273
|
cleanup?: { overlay?: boolean; forms?: boolean; scroll?: boolean; storage?: boolean };
|
|
263
274
|
backgroundSteps?: Array<{ comment?: string; code: string }>;
|
|
264
275
|
scenarios: string[];
|
|
265
276
|
authGroups?: Array<{ authRole?: string; scenarios: string[] }>;
|
|
266
277
|
singleAuthRole?: string;
|
|
278
|
+
appPackage?: string;
|
|
279
|
+
iosBundleId?: string;
|
|
267
280
|
}): string {
|
|
268
281
|
return this.render('test-file', data);
|
|
269
282
|
}
|
|
@@ -20,6 +20,12 @@ interface SelectorEntry {
|
|
|
20
20
|
scope?: string; // Parent landmark aria-label to scope within (e.g., 'desktop navigation')
|
|
21
21
|
match?: 'exact' | 'partial'; // For getByText matching (default: partial)
|
|
22
22
|
|
|
23
|
+
// === Mobile per-platform variant (issue #392) ===
|
|
24
|
+
// `android:`/`ios:` sub-selectors for one logical element whose locator differs per OS (composite
|
|
25
|
+
// content-desc, partial/dynamic text, native predicate). Each is a full SelectorEntry.
|
|
26
|
+
android?: SelectorEntry;
|
|
27
|
+
ios?: SelectorEntry;
|
|
28
|
+
|
|
23
29
|
// === v2: Complex Elements ===
|
|
24
30
|
variant?: 'native' | 'custom' | 'dragdrop'; // Dropdown/upload subtype
|
|
25
31
|
trigger?: string; // Visible button that triggers hidden file input
|
|
@@ -52,8 +58,17 @@ interface LegacySelectorFile {
|
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
export interface ResolvedSelector {
|
|
55
|
-
|
|
61
|
+
// Web (Playwright) strategies + mobile (Appium) strategies. Mobile types pass through from
|
|
62
|
+
// selectors.yaml so the appium adapter's `appium-selector` switch can emit the right WDIO selector
|
|
63
|
+
// (`~` / raw xpath / `android=` / `-ios predicate string:` / `id=`). See issue #392.
|
|
64
|
+
strategy: 'testid' | 'id' | 'placeholder' | 'label' | 'text' | 'css' | 'role' | 'locator'
|
|
65
|
+
| 'accessibility-id' | 'xpath' | 'android-uiautomator' | 'ios-predicate';
|
|
56
66
|
value: string;
|
|
67
|
+
// Mobile per-platform variant (`android:`/`ios:` in selectors.yaml). When set, the appium codegen
|
|
68
|
+
// emits a runtime `driver.isIOS ? '<ios>' : '<android>'` so one @platform:mobile spec resolves the
|
|
69
|
+
// right native selector per OS. Each side carries its own strategy+value (issue #392).
|
|
70
|
+
androidSelector?: { strategy: ResolvedSelector['strategy']; value: string; name?: string };
|
|
71
|
+
iosSelector?: { strategy: ResolvedSelector['strategy']; value: string; name?: string };
|
|
57
72
|
role?: string; // For role-based selectors (e.g., 'button', 'link')
|
|
58
73
|
name?: string; // For accessible name in role selectors
|
|
59
74
|
locator?: string; // CSS locator for fallback
|
|
@@ -472,6 +487,25 @@ export class SelectorResolver {
|
|
|
472
487
|
return { ...resolved, ...v2Fields };
|
|
473
488
|
};
|
|
474
489
|
|
|
490
|
+
// Mobile per-platform variant (issue #392): `{ android: {...}, ios: {...} }`. Resolve each side
|
|
491
|
+
// and emit androidSelector/iosSelector so the appium codegen renders `driver.isIOS ? ios : android`.
|
|
492
|
+
// The base strategy/value falls back to whichever side is present (a missing side falls back to it).
|
|
493
|
+
// `nth`/`scope` are shared (top-level) — they cannot diverge per-OS (the codegen ternary swaps only
|
|
494
|
+
// strategy+value); a case needing per-OS nth/scope is a sub-feature, not a variant.
|
|
495
|
+
if (entry.android || entry.ios) {
|
|
496
|
+
const av = entry.android ? this.resolveFromEntry(entry.android, originalLabel) : undefined;
|
|
497
|
+
const iv = entry.ios ? this.resolveFromEntry(entry.ios, originalLabel) : undefined;
|
|
498
|
+
const base = (av || iv) as ResolvedSelector;
|
|
499
|
+
return withExtras({
|
|
500
|
+
strategy: base.strategy,
|
|
501
|
+
value: base.value,
|
|
502
|
+
name: base.name,
|
|
503
|
+
nth,
|
|
504
|
+
...(av ? { androidSelector: { strategy: av.strategy, value: av.value, name: av.name } } : {}),
|
|
505
|
+
...(iv ? { iosSelector: { strategy: iv.strategy, value: iv.value, name: iv.name } } : {}),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
475
509
|
// If type is 'locator', use locator field (or value as fallback) as the CSS locator directly
|
|
476
510
|
if (type === 'locator') {
|
|
477
511
|
const locatorValue = locator && locator.trim() ? locator : value;
|
|
@@ -536,6 +570,22 @@ export class SelectorResolver {
|
|
|
536
570
|
exact,
|
|
537
571
|
});
|
|
538
572
|
|
|
573
|
+
// Mobile (Appium) native strategies — pass through unchanged; the appium adapter's
|
|
574
|
+
// `appium-selector` switch renders the right WDIO selector (`~` / raw xpath / `android=` /
|
|
575
|
+
// `-ios predicate string:` / `id=`). On web these types never appear. See issue #392.
|
|
576
|
+
case 'accessibility-id':
|
|
577
|
+
case 'xpath':
|
|
578
|
+
case 'android-uiautomator':
|
|
579
|
+
case 'ios-predicate':
|
|
580
|
+
case 'id':
|
|
581
|
+
return withExtras({
|
|
582
|
+
strategy: type,
|
|
583
|
+
value,
|
|
584
|
+
name,
|
|
585
|
+
nth,
|
|
586
|
+
exact,
|
|
587
|
+
});
|
|
588
|
+
|
|
539
589
|
default:
|
|
540
590
|
// Fallback to placeholder
|
|
541
591
|
return withExtras({
|