peachsim 0.1.629 → 0.1.631
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/README.md +8 -4
- package/cli/app-fonts.ts +23 -4
- package/cli/bridge-flow-runner.ts +45 -31
- package/cli/cli-update.ts +2 -7
- package/cli/cloud-test-tunnel.ts +16 -3
- package/cli/command-registry.ts +1 -0
- package/cli/commands/compat.ts +11 -60
- package/cli/commands/config.ts +1 -1
- package/cli/commands/control.ts +40 -8
- package/cli/commands/daemon-mac-app.ts +4 -2
- package/cli/commands/daemon.ts +4 -4
- package/cli/commands/detect.ts +289 -26
- package/cli/commands/device.ts +2 -3
- package/cli/commands/inspect/actions.ts +125 -90
- package/cli/commands/inspect/core.ts +1 -1
- package/cli/commands/inspect.ts +10 -1
- package/cli/commands/maestro.ts +3 -1
- package/cli/commands/platform.ts +1 -1
- package/cli/commands/project-report.ts +386 -0
- package/cli/commands/projectReportContract.ts +42 -0
- package/cli/commands/report-issue.ts +38 -54
- package/cli/commands/screenshot-layers.ts +1 -1
- package/cli/commands/screenshot.ts +1 -1
- package/cli/commands/setup.ts +6 -4
- package/cli/commands/skills.ts +0 -31
- package/cli/commands/telemetry.ts +8 -3
- package/cli/commands/test.ts +347 -134
- package/cli/drivers/playwright-provisioning.ts +3 -2
- package/cli/drivers/playwright-sim-host.ts +25 -9
- package/cli/main.ts +19 -3
- package/cli/outbound-endpoints.ts +2 -5
- package/cli/privacy.ts +1 -2
- package/cli/runtime-diagnostics.ts +51 -0
- package/cli/telemetry.ts +78 -7
- package/detox/index.ts +77 -51
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +130 -33
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +8 -5
- package/dist-lib/host/bridge-host.cjs +353 -290
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +257 -121
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/{vite-kotlin.cjs → kotlin-host.cjs} +130 -19
- package/dist-lib/menu.cjs +11 -1
- package/dist-lib/menu.mjs +11 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +132 -9
- package/dist-lib/native-app-identity.cjs +278 -0
- package/dist-lib/native-app-identity.mjs +245 -0
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +112 -87
- package/dist-lib/sdk.mjs +111 -87
- package/dist-lib/skills.cjs +1270 -709
- package/dist-lib/swift-compile.cjs +1 -1
- package/dist-lib/swift-compile.mjs +1 -1
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift.cjs +672 -275
- package/dist-lib/vite-swift.cjs +1 -1
- package/dist-lib/vite.cjs +11 -28
- package/kotlin/README.md +32 -4
- package/kotlin/fixtures/counter/Counter.kt +26 -0
- package/kotlin/fixtures/counter/CounterScreen.kt +40 -0
- package/kotlin/fixtures/headless-counter/Headless.kt +228 -0
- package/kotlin/fixtures/runtime-state/RuntimeState.kt +200 -0
- package/kotlin/fork/LICENSE +178 -0
- package/kotlin/fork/NOTICE +25 -0
- package/kotlin/fork/README.md +59 -0
- package/kotlin/fork/build.sh +128 -0
- package/kotlin/fork/compose-worker-globals.patch +96 -0
- package/kotlin/fork/fork.json +33 -0
- package/kotlin/fork/publish.ts +77 -0
- package/kotlin/fork/skiko-worker-clock.patch +33 -0
- package/kotlin/toolchain.json +288 -0
- package/package.json +13 -5
- package/skills/contrast/SKILL.md +1 -0
- package/src/automatic-update-policy.ts +17 -0
- package/src/home-paths.ts +11 -9
- package/src/host/bridge-host.ts +3 -1
- package/src/{vite-plugin-kotlin.ts → kotlin-host.ts} +155 -12
- package/src/menu.ts +11 -0
- package/src/metro-plugin.ts +18 -0
- package/src/native-app-identity.ts +376 -0
- package/src/native-firebase-config.ts +90 -0
- package/src/screenshots/frame-compose.ts +1 -1
- package/src/screenshots/registry.ts +1 -1
- package/src/screenshots/schema.ts +2 -2
- package/src/vite-plugin-one.ts +11 -28
- package/src/vite-plugin.ts +6 -1
package/cli/commands/setup.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from '../../src/home-paths'
|
|
18
18
|
import { rnxRuntime } from '../../src/runtime-delivery'
|
|
19
19
|
import { findDesktopCompanion } from '../desktop-companion'
|
|
20
|
+
import { canPromptForPrivacyChoice } from '../privacy'
|
|
20
21
|
import { confirm, select } from '../prompt'
|
|
21
22
|
import {
|
|
22
23
|
applyRepositorySetup,
|
|
@@ -342,11 +343,12 @@ async function decidePrivacy(
|
|
|
342
343
|
): Promise<boolean> {
|
|
343
344
|
if (options.productAnalytics !== undefined) return options.productAnalytics
|
|
344
345
|
if (current.configured) return current.productAnalytics
|
|
345
|
-
if (options.yes || options.ci || !
|
|
346
|
+
if (options.yes || options.ci || !canPromptForPrivacyChoice()) return false
|
|
346
347
|
printPromptBlock('Private by default', [
|
|
347
|
-
'Diagnostics stay off unless you opt in.',
|
|
348
|
-
'Reports
|
|
349
|
-
'They
|
|
348
|
+
'Diagnostics stay off unless you opt in, including CI, pipes and agent runs. DO_NOT_TRACK overrides opt-in.',
|
|
349
|
+
'Reports include CLI/runtime versions, OS/arch, command name and a random install ID.',
|
|
350
|
+
'They include package incompatibilities, error classes and first-line error/warning messages (300 characters, paths redacted).',
|
|
351
|
+
'Reports exclude arguments, stacks, app source and structured app data.',
|
|
350
352
|
])
|
|
351
353
|
return confirm('Share bounded diagnostics to improve peach?', false)
|
|
352
354
|
}
|
package/cli/commands/skills.ts
CHANGED
|
@@ -286,37 +286,6 @@ function linkSkill(skill: SkillInfo, targetDir: string, force: boolean): LinkRes
|
|
|
286
286
|
return 'conflict'
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
-
// quiet, conflict-safe (re)install of all bundled skills into the agent stores
|
|
290
|
-
// that already exist on this machine. wired into the CLI build so building or
|
|
291
|
-
// updating the CLI re-establishes the symlinks idempotently — never forces, so
|
|
292
|
-
// it can't clobber a skill the user customized, and never creates a store for
|
|
293
|
-
// an agent that isn't set up here. silent on success.
|
|
294
|
-
export function refreshInstalledSkills() {
|
|
295
|
-
const skills = listSkills()
|
|
296
|
-
if (skills.length === 0) return
|
|
297
|
-
for (const target of defaultAgentTargets()) {
|
|
298
|
-
const skillsDir = path.resolve(target.dir)
|
|
299
|
-
// only touch agents already present (~/.claude, ~/.codex) — don't seed a
|
|
300
|
-
// store on a CI runner or a machine that doesn't use that agent.
|
|
301
|
-
if (!fs.existsSync(path.dirname(skillsDir))) continue
|
|
302
|
-
try {
|
|
303
|
-
fs.mkdirSync(skillsDir, { recursive: true })
|
|
304
|
-
} catch {
|
|
305
|
-
continue
|
|
306
|
-
}
|
|
307
|
-
try {
|
|
308
|
-
removeLegacyBundledSkills(skillsDir)
|
|
309
|
-
} catch {}
|
|
310
|
-
for (const skill of skills) {
|
|
311
|
-
try {
|
|
312
|
-
linkSkill(skill, skillsDir, false)
|
|
313
|
-
} catch {
|
|
314
|
-
// best-effort: a single bad store must not fail the build
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
289
|
const LEGACY_BUNDLED_SKILL_DIRS = [
|
|
321
290
|
'sootsim-setup',
|
|
322
291
|
'sootsim-debug',
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// product analytics preference shown by guided setup and Settings.
|
|
3
3
|
//
|
|
4
4
|
// this is the process-wide preference; `DO_NOT_TRACK=1` still forces opt-out
|
|
5
|
-
// for a single run regardless of
|
|
6
|
-
// telemetry.ts, the same cross-tool signal npm/turbo/supabase use).
|
|
5
|
+
// for a single run regardless of stored consent, including CI and agents.
|
|
7
6
|
|
|
8
7
|
import {
|
|
9
8
|
configFilePath,
|
|
@@ -16,7 +15,13 @@ function printStatus(): void {
|
|
|
16
15
|
const preferences = readPrivacyPreferences()
|
|
17
16
|
const enabled = preferences.configured && preferences.productAnalytics
|
|
18
17
|
console.log(` bounded diagnostics are ${enabled ? 'ON' : 'OFF'}`)
|
|
19
|
-
console.log('
|
|
18
|
+
console.log(' fields: CLI/runtime versions, OS/arch, command name, random install ID')
|
|
19
|
+
console.log(
|
|
20
|
+
' diagnostics: package name/version/unsupported API; error class and first-line error/warning messages (300 characters, paths redacted)',
|
|
21
|
+
)
|
|
22
|
+
console.log(
|
|
23
|
+
' explicit opt-in also applies in CI, pipes and agent runs; DO_NOT_TRACK wins',
|
|
24
|
+
)
|
|
20
25
|
console.log(` config: ${configFilePath()}`)
|
|
21
26
|
const doNotTrack = process.env.DO_NOT_TRACK
|
|
22
27
|
if (doNotTrack === '1' || doNotTrack === 'true') {
|
package/cli/commands/test.ts
CHANGED
|
@@ -455,6 +455,24 @@ function collectFlowHelperPaths(flowAbsPath: string): string[] {
|
|
|
455
455
|
return helpers
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
// a synthesized workspace config that selects exactly one flow. bun's glob
|
|
459
|
+
// (node:fs.globSync, which the planner reads) treats * ? [ ] { } as special
|
|
460
|
+
// and has no backslash escape, so each is wrapped in a one-character class; a
|
|
461
|
+
// leading `!` would negate the pattern, so it is anchored under ./.
|
|
462
|
+
function selectionConfig(entryKey: string): string {
|
|
463
|
+
if (entryKey.includes('\\')) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
`cloud test cannot select a flow whose path contains a backslash: ${entryKey}`,
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
const escaped = entryKey.replace(/[*?[\]{}]/g, (char) => `[${char}]`)
|
|
469
|
+
const glob = escaped.startsWith('!') ? `./${escaped}` : escaped
|
|
470
|
+
return (
|
|
471
|
+
'# synthesized by peach test --cloud: run exactly the submitted entry flow.\n' +
|
|
472
|
+
`flows:\n - ${JSON.stringify(glob)}\n`
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
458
476
|
function collectTestFiles(
|
|
459
477
|
targetPath: string,
|
|
460
478
|
ignoredPath: string | null,
|
|
@@ -522,17 +540,14 @@ function collectTestFiles(
|
|
|
522
540
|
// the recorder runs an unpacked multi-file upload as a workspace,
|
|
523
541
|
// whose default glob only sees top-level flows: a nested entry would
|
|
524
542
|
// lose to a top-level helper. pin a config that selects the entry.
|
|
525
|
-
if (!keys.some((key) => /^config\.ya?ml$/.test(
|
|
543
|
+
if (!keys.some((key) => /^config\.ya?ml$/.test(key))) {
|
|
526
544
|
if (Object.keys(files).length >= RNX_CLOUD_TEST_FILES_MAX_COUNT) {
|
|
527
545
|
throw new Error(
|
|
528
546
|
`cloud test suites may contain at most ${RNX_CLOUD_TEST_FILES_MAX_COUNT} files`,
|
|
529
547
|
)
|
|
530
548
|
}
|
|
531
549
|
const entryKey = keys[0] as string
|
|
532
|
-
|
|
533
|
-
files['config.yaml'] =
|
|
534
|
-
'# synthesized by peach test --cloud: run exactly the submitted entry flow.\n' +
|
|
535
|
-
`flows:\n - ${JSON.stringify(glob)}\n`
|
|
550
|
+
files['config.yaml'] = selectionConfig(entryKey)
|
|
536
551
|
}
|
|
537
552
|
}
|
|
538
553
|
for (const configName of [
|
|
@@ -682,11 +697,16 @@ export async function runCloudTest(
|
|
|
682
697
|
return 1
|
|
683
698
|
}
|
|
684
699
|
|
|
685
|
-
|
|
700
|
+
let targetPath = resolve(process.cwd(), target)
|
|
686
701
|
if (!existsSync(targetPath)) {
|
|
687
702
|
console.error(` error: test target not found: ${target}`)
|
|
688
703
|
return 1
|
|
689
704
|
}
|
|
705
|
+
// a file literally named config.yaml names a workspace, not a flow: local
|
|
706
|
+
// dispatch runs its directory, so hosted planning does too.
|
|
707
|
+
if (statSync(targetPath).isFile() && /^config\.ya?ml$/.test(basename(targetPath))) {
|
|
708
|
+
targetPath = dirname(targetPath)
|
|
709
|
+
}
|
|
690
710
|
|
|
691
711
|
if (!app) {
|
|
692
712
|
console.error(
|
|
@@ -798,21 +818,135 @@ export async function runCloudTest(
|
|
|
798
818
|
app.includes('/api/preview/share/bundle')
|
|
799
819
|
? null
|
|
800
820
|
: resolve(process.cwd(), app)
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
821
|
+
|
|
822
|
+
// one hosted job per Maestro flow. a suite whose total executor time passes
|
|
823
|
+
// the 45 minute per-job cap still completes when every flow stays under it,
|
|
824
|
+
// so a directory target uploads the whole original file set (flows and
|
|
825
|
+
// helpers byte-identical) plus a synthesized config selecting one flow per
|
|
826
|
+
// job. tag selection resolves here, before any submission, exactly as a
|
|
827
|
+
// local workspace run resolves it: the CLI planner reads the workspace
|
|
828
|
+
// config, applies CLI include/exclude overrides, and the selected flows are
|
|
829
|
+
// the jobs. the jobs run one at a time because live sidecars can share
|
|
830
|
+
// mutable backend state. detox suites and explicit single flows keep their
|
|
831
|
+
// existing one-job whole-suite path.
|
|
832
|
+
const jobs: Array<{
|
|
833
|
+
label: string | null
|
|
834
|
+
testFiles: Record<string, string>
|
|
835
|
+
testArgs: string[] | null
|
|
836
|
+
}> = []
|
|
837
|
+
const hasConfig = forwarded.some(
|
|
838
|
+
(arg) => arg === '--config' || arg.startsWith('--config='),
|
|
839
|
+
)
|
|
840
|
+
const maestroConfig = hasConfig && isMaestroConfigValue(config)
|
|
841
|
+
const maestroTarget = (() => {
|
|
842
|
+
if (hasConfig && !maestroConfig) return false
|
|
843
|
+
if (!statSync(targetPath).isDirectory()) {
|
|
844
|
+
return /\.ya?ml$/.test(extname(targetPath))
|
|
845
|
+
}
|
|
846
|
+
return (
|
|
847
|
+
readdirSync(targetPath).some((name) => /\.ya?ml$/.test(name)) ||
|
|
848
|
+
['.maestro', 'maestro'].includes(basename(targetPath))
|
|
849
|
+
)
|
|
850
|
+
})()
|
|
851
|
+
if (maestroTarget) {
|
|
852
|
+
// maestro has no test-name or test-path selection; the cloud selector
|
|
853
|
+
// vocabulary is shared with detox, so a jest selector here is refused
|
|
854
|
+
// before submission rather than silently dropped from the hosted job.
|
|
855
|
+
const unsupported = (cloudTestArgs ?? []).filter((arg) => {
|
|
856
|
+
const eq = arg.indexOf('=')
|
|
857
|
+
const head = eq > 0 ? arg.slice(0, eq) : arg
|
|
858
|
+
return ['-t', '--testNamePattern', '--testPathPattern', '--grep'].includes(head)
|
|
859
|
+
})
|
|
860
|
+
if (unsupported.length > 0) {
|
|
861
|
+
for (const arg of unsupported) {
|
|
862
|
+
const eq = arg.indexOf('=')
|
|
863
|
+
const head = eq > 0 ? arg.slice(0, eq) : arg
|
|
864
|
+
console.error(
|
|
865
|
+
` error: ${head} selects detox/jest tests and does not apply to a maestro target`,
|
|
866
|
+
)
|
|
867
|
+
}
|
|
868
|
+
return 1
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (maestroTarget && statSync(targetPath).isDirectory()) {
|
|
872
|
+
const { collectFlowFiles, splitTagList } = await import('./maestro')
|
|
873
|
+
const tagFlag = (name: string): string[] | null => {
|
|
874
|
+
let values: string[] | null = null
|
|
875
|
+
for (let i = 0; i < forwarded.length; i++) {
|
|
876
|
+
const arg = forwarded[i]
|
|
877
|
+
const eq = arg.indexOf('=')
|
|
878
|
+
const head = eq > 0 ? arg.slice(0, eq) : arg
|
|
879
|
+
if (head !== name) continue
|
|
880
|
+
const inline = eq > 0 ? arg.slice(eq + 1) : null
|
|
881
|
+
const next = forwarded[i + 1]
|
|
882
|
+
const value = inline ?? (next !== undefined && !next.startsWith('-') ? next : '')
|
|
883
|
+
if (inline === null && next !== undefined && !next.startsWith('-')) i += 1
|
|
884
|
+
if (values === null) values = []
|
|
885
|
+
values.push(...splitTagList(value))
|
|
886
|
+
}
|
|
887
|
+
return values
|
|
888
|
+
}
|
|
889
|
+
const includeTags = tagFlag('--include-tags')
|
|
890
|
+
const excludeTags = tagFlag('--exclude-tags')
|
|
891
|
+
let flows: string[]
|
|
892
|
+
try {
|
|
893
|
+
flows = collectFlowFiles(targetPath, {
|
|
894
|
+
configPath: config ? resolve(process.cwd(), config) : undefined,
|
|
895
|
+
includeTags,
|
|
896
|
+
excludeTags,
|
|
897
|
+
})
|
|
898
|
+
} catch (err) {
|
|
899
|
+
console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
|
|
900
|
+
return 1
|
|
901
|
+
}
|
|
902
|
+
if (flows.length === 0) {
|
|
903
|
+
console.error(
|
|
904
|
+
includeTags !== null || excludeTags !== null
|
|
905
|
+
? ` error: no flows in ${target} match the requested tags`
|
|
906
|
+
: ` error: no *.yaml or *.yml files found in ${target}`,
|
|
907
|
+
)
|
|
908
|
+
return 1
|
|
909
|
+
}
|
|
910
|
+
const baseFiles = collectTestFiles(targetPath, localBundlePath)
|
|
911
|
+
for (const flow of flows) {
|
|
912
|
+
const entryKey = relative(targetPath, flow).split(sep).join('/')
|
|
913
|
+
let generated: string
|
|
914
|
+
try {
|
|
915
|
+
generated = selectionConfig(entryKey)
|
|
916
|
+
} catch (err) {
|
|
917
|
+
console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
|
|
918
|
+
return 1
|
|
919
|
+
}
|
|
920
|
+
// the recorder selects the root workspace config. nested config.yaml
|
|
921
|
+
// files may be runFlow helpers and must keep their original bytes.
|
|
922
|
+
const testFiles = { ...baseFiles, 'config.yaml': generated }
|
|
923
|
+
jobs.push({ label: entryKey, testFiles, testArgs: null })
|
|
924
|
+
}
|
|
925
|
+
console.log(
|
|
926
|
+
` cloud test: ${jobs.length} flow${jobs.length === 1 ? '' : 's'}, one hosted job each`,
|
|
927
|
+
)
|
|
928
|
+
} else {
|
|
929
|
+
const testFiles = collectTestFiles(collectionPath, localBundlePath)
|
|
930
|
+
if (Object.keys(testFiles).length === 0) {
|
|
931
|
+
console.error(` error: no test files found at ${target}`)
|
|
932
|
+
return 1
|
|
933
|
+
}
|
|
934
|
+
jobs.push({ label: null, testFiles, testArgs: cloudTestArgs })
|
|
805
935
|
}
|
|
806
936
|
|
|
807
|
-
// the tunnel stays open from submit until
|
|
808
|
-
// state; tearing it down early strands the recorder mid-run.
|
|
937
|
+
// the tunnel stays open from the first submit until every job reaches a
|
|
938
|
+
// terminal state; tearing it down early strands the recorder mid-run.
|
|
809
939
|
if (liveDevStack && liveSidecarPorts) {
|
|
810
940
|
try {
|
|
811
941
|
const published = await publishLiveDevStack({
|
|
812
942
|
devPort: liveDevStack.port,
|
|
813
943
|
sidecarPorts: liveSidecarPorts,
|
|
814
944
|
})
|
|
815
|
-
|
|
945
|
+
const appUrl = new URL(app)
|
|
946
|
+
const targetUrl = new URL(published.targetUrl)
|
|
947
|
+
targetUrl.pathname = appUrl.pathname
|
|
948
|
+
targetUrl.search = appUrl.search
|
|
949
|
+
tunnelTargetUrl = targetUrl.href
|
|
816
950
|
tunnelHostHeader = published.hostHeader
|
|
817
951
|
tunnelJobToken = published.jobToken
|
|
818
952
|
tunnelSidecarPorts = published.sidecarPorts.join(',') || undefined
|
|
@@ -824,150 +958,229 @@ export async function runCloudTest(
|
|
|
824
958
|
}
|
|
825
959
|
}
|
|
826
960
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
961
|
+
// a signal stops the run: it lets a submission already in flight finish, so
|
|
962
|
+
// an accepted job is never orphaned, cancels the job currently relying on
|
|
963
|
+
// this process's tunnel, and only then lets the tunnel close. the listener
|
|
964
|
+
// is installed once for the whole invocation and removed on the way out; a
|
|
965
|
+
// second signal waits for that cancellation instead of closing the tunnel
|
|
966
|
+
// while a delete is still pending.
|
|
967
|
+
const tunnelDependent = closeTunnel !== null
|
|
968
|
+
let interrupted = false
|
|
969
|
+
let wake: (() => void) | null = null
|
|
970
|
+
const onSignal = () => {
|
|
971
|
+
if (interrupted) {
|
|
972
|
+
console.error(' already stopping: waiting for the current job to cancel')
|
|
973
|
+
return
|
|
974
|
+
}
|
|
975
|
+
interrupted = true
|
|
976
|
+
console.error('\n interrupted: stopping after the current job')
|
|
977
|
+
wake?.()
|
|
978
|
+
}
|
|
979
|
+
if (tunnelDependent) {
|
|
980
|
+
process.on('SIGINT', onSignal)
|
|
981
|
+
process.on('SIGTERM', onSignal)
|
|
830
982
|
}
|
|
831
|
-
if (bundleUrl) submitPayload.bundleUrl = bundleUrl
|
|
832
|
-
if (bundle) submitPayload.bundle = bundle
|
|
833
|
-
if (bundleShareId) submitPayload.bundleShareId = bundleShareId
|
|
834
|
-
if (tunnelTargetUrl) submitPayload.targetUrl = tunnelTargetUrl
|
|
835
|
-
if (tunnelHostHeader) submitPayload.hostHeader = tunnelHostHeader
|
|
836
|
-
if (tunnelJobToken) submitPayload.jobToken = tunnelJobToken
|
|
837
|
-
if (tunnelSidecarPorts) submitPayload.sidecarPorts = tunnelSidecarPorts
|
|
838
|
-
if (owner) submitPayload.owner = owner
|
|
839
|
-
if (repo) submitPayload.repo = repo
|
|
840
|
-
if (cloudTestArgs) submitPayload.testArgs = cloudTestArgs
|
|
841
983
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
|
|
857
|
-
)
|
|
858
|
-
return 1
|
|
859
|
-
}
|
|
984
|
+
// a sleep the signal handler can cut short, so an interrupt is noticed while
|
|
985
|
+
// a job poll is waiting instead of after the whole interval.
|
|
986
|
+
const waitForPoll = (ms: number) =>
|
|
987
|
+
new Promise<void>((resolvePoll) => {
|
|
988
|
+
const timer = setTimeout(() => {
|
|
989
|
+
wake = null
|
|
990
|
+
resolvePoll()
|
|
991
|
+
}, ms)
|
|
992
|
+
wake = () => {
|
|
993
|
+
clearTimeout(timer)
|
|
994
|
+
wake = null
|
|
995
|
+
resolvePoll()
|
|
996
|
+
}
|
|
997
|
+
})
|
|
860
998
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
return 1
|
|
872
|
-
}
|
|
999
|
+
const submitIdentity: Record<string, unknown> = {}
|
|
1000
|
+
if (bundleUrl) submitIdentity.bundleUrl = bundleUrl
|
|
1001
|
+
if (bundle) submitIdentity.bundle = bundle
|
|
1002
|
+
if (bundleShareId) submitIdentity.bundleShareId = bundleShareId
|
|
1003
|
+
if (tunnelTargetUrl) submitIdentity.targetUrl = tunnelTargetUrl
|
|
1004
|
+
if (tunnelHostHeader) submitIdentity.hostHeader = tunnelHostHeader
|
|
1005
|
+
if (tunnelJobToken) submitIdentity.jobToken = tunnelJobToken
|
|
1006
|
+
if (tunnelSidecarPorts) submitIdentity.sidecarPorts = tunnelSidecarPorts
|
|
1007
|
+
if (owner) submitIdentity.owner = owner
|
|
1008
|
+
if (repo) submitIdentity.repo = repo
|
|
873
1009
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
void cancelCloudTest(id, origin).finally(() => {
|
|
883
|
-
closeTunnel?.()
|
|
884
|
-
process.exit(130)
|
|
885
|
-
})
|
|
1010
|
+
const summary: Array<{ label: string; passed: boolean }> = []
|
|
1011
|
+
let worstExit = 0
|
|
1012
|
+
try {
|
|
1013
|
+
for (const [index, job] of jobs.entries()) {
|
|
1014
|
+
const submitPayload: Record<string, unknown> = {
|
|
1015
|
+
kind: 'test',
|
|
1016
|
+
testFiles: job.testFiles,
|
|
1017
|
+
...submitIdentity,
|
|
886
1018
|
}
|
|
887
|
-
|
|
888
|
-
process.once('SIGTERM', cancelOnSignal)
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
|
|
892
|
-
const startedAt = Date.now()
|
|
1019
|
+
if (job.testArgs) submitPayload.testArgs = job.testArgs
|
|
893
1020
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
let pollRes: Response
|
|
1021
|
+
const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
|
|
1022
|
+
let response: Response
|
|
897
1023
|
try {
|
|
898
|
-
|
|
899
|
-
|
|
1024
|
+
response = await fetch(endpoint, {
|
|
1025
|
+
method: 'POST',
|
|
1026
|
+
headers: {
|
|
1027
|
+
'content-type': 'application/json',
|
|
1028
|
+
authorization: authHeaderValue(auth),
|
|
1029
|
+
},
|
|
1030
|
+
body: JSON.stringify(submitPayload),
|
|
900
1031
|
})
|
|
901
1032
|
} catch (err) {
|
|
902
|
-
|
|
903
|
-
|
|
1033
|
+
// a transport failure is not a flow failure: stop submitting further
|
|
1034
|
+
// jobs and report. jobs that already ran keep their collected results.
|
|
1035
|
+
console.error(
|
|
1036
|
+
` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
|
|
904
1037
|
)
|
|
905
|
-
|
|
1038
|
+
if (interrupted) break
|
|
1039
|
+
return 1
|
|
906
1040
|
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1041
|
+
|
|
1042
|
+
if (!response.ok) {
|
|
1043
|
+
const body = await response.text()
|
|
1044
|
+
let message = body
|
|
1045
|
+
try {
|
|
1046
|
+
const json = JSON.parse(body)
|
|
1047
|
+
if (json.message) message = json.message
|
|
1048
|
+
} catch {}
|
|
1049
|
+
console.error(
|
|
1050
|
+
` error: cloud test submission failed (${response.status}): ${message}`,
|
|
1051
|
+
)
|
|
1052
|
+
if (interrupted) break
|
|
1053
|
+
return 1
|
|
910
1054
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
}> | null
|
|
924
|
-
exitCode?: number | null
|
|
925
|
-
failureReason?: string | null
|
|
926
|
-
} | null
|
|
1055
|
+
|
|
1056
|
+
const { id } = (await response.json()) as { id: string }
|
|
1057
|
+
console.log(
|
|
1058
|
+
jobs.length > 1
|
|
1059
|
+
? ` cloud test job ${index + 1}/${jobs.length} (${job.label}) submitted: ${id}`
|
|
1060
|
+
: ` cloud test job submitted: ${id}`,
|
|
1061
|
+
)
|
|
1062
|
+
if (interrupted) {
|
|
1063
|
+
// the signal arrived while the POST was in flight: the server accepted
|
|
1064
|
+
// the job, so cancel it before the tunnel closes.
|
|
1065
|
+
if (tunnelDependent) await cancelCloudTest(id, origin)
|
|
1066
|
+
break
|
|
927
1067
|
}
|
|
928
1068
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
|
|
1069
|
+
const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
|
|
1070
|
+
const startedAt = Date.now()
|
|
1071
|
+
let exitCode: number | null = null
|
|
1072
|
+
|
|
1073
|
+
// every flow keeps the job's own budget; a wait that expires cancels
|
|
1074
|
+
// the job before the tunnel closes, and later flows are not submitted.
|
|
1075
|
+
while (!interrupted && Date.now() - startedAt < timeoutMs) {
|
|
1076
|
+
await waitForPoll(pollIntervalMs)
|
|
1077
|
+
if (interrupted) break
|
|
1078
|
+
let pollRes: Response
|
|
1079
|
+
try {
|
|
1080
|
+
pollRes = await fetch(pollUrl, {
|
|
1081
|
+
headers: { authorization: authHeaderValue(auth) },
|
|
1082
|
+
})
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
console.warn(
|
|
1085
|
+
` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
|
|
947
1086
|
)
|
|
1087
|
+
continue
|
|
1088
|
+
}
|
|
1089
|
+
if (interrupted) break
|
|
1090
|
+
if (!pollRes.ok) {
|
|
1091
|
+
console.warn(` warn: polling error: HTTP ${pollRes.status}`)
|
|
1092
|
+
continue
|
|
948
1093
|
}
|
|
949
|
-
|
|
950
|
-
|
|
1094
|
+
const jobState = (await pollRes.json()) as {
|
|
1095
|
+
id: string
|
|
1096
|
+
status: string
|
|
1097
|
+
result?: {
|
|
1098
|
+
previewId?: string | null
|
|
1099
|
+
previewUrl?: string | null
|
|
1100
|
+
runId?: string | null
|
|
1101
|
+
flows?: Array<{
|
|
1102
|
+
flow?: string | null
|
|
1103
|
+
previewId?: string | null
|
|
1104
|
+
previewUrl?: string | null
|
|
1105
|
+
runId?: string | null
|
|
1106
|
+
}> | null
|
|
1107
|
+
exitCode?: number | null
|
|
1108
|
+
failureReason?: string | null
|
|
1109
|
+
} | null
|
|
951
1110
|
}
|
|
952
|
-
|
|
953
|
-
|
|
1111
|
+
|
|
1112
|
+
if (
|
|
1113
|
+
jobState.status === 'done' ||
|
|
1114
|
+
jobState.status === 'failed' ||
|
|
1115
|
+
jobState.status === 'expired' ||
|
|
1116
|
+
jobState.status === 'cancelled'
|
|
1117
|
+
) {
|
|
1118
|
+
const result = jobState.result
|
|
1119
|
+
// a job the recorder failed after a passing suite (missing recording or
|
|
1120
|
+
// registration) is still a failed run.
|
|
1121
|
+
exitCode =
|
|
1122
|
+
jobState.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
|
|
1123
|
+
console.log(` status: ${jobState.status}`)
|
|
1124
|
+
console.log(` exit code: ${exitCode}`)
|
|
1125
|
+
if (result?.previewUrl) {
|
|
1126
|
+
console.log(` preview: ${result.previewUrl}`)
|
|
1127
|
+
} else if (result?.previewId) {
|
|
954
1128
|
console.log(
|
|
955
|
-
`
|
|
1129
|
+
` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
|
|
956
1130
|
)
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1131
|
+
}
|
|
1132
|
+
if (result?.runId) {
|
|
1133
|
+
console.log(` run: ${result.runId}`)
|
|
1134
|
+
}
|
|
1135
|
+
if (result?.flows && (result.flows.length > 1 || jobs.length > 1)) {
|
|
1136
|
+
result.flows.forEach((flow, flowIndex) => {
|
|
1137
|
+
console.log(
|
|
1138
|
+
` flow ${flowIndex + 1}: ${flow.flow ?? job.label ?? '(unknown flow)'} preview=${flow.previewUrl ?? flow.previewId ?? 'none'} run=${flow.runId ?? 'none'}`,
|
|
1139
|
+
)
|
|
1140
|
+
})
|
|
1141
|
+
}
|
|
1142
|
+
if (result?.failureReason) {
|
|
1143
|
+
console.error(` failure: ${result.failureReason}`)
|
|
1144
|
+
}
|
|
1145
|
+
break
|
|
961
1146
|
}
|
|
962
|
-
return exitCode
|
|
963
1147
|
}
|
|
964
|
-
}
|
|
965
1148
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1149
|
+
if (exitCode === null) {
|
|
1150
|
+
// the wait ended without a terminal state: the budget ran out or a
|
|
1151
|
+
// signal asked to stop. a tunnel-dependent job is cancelled before
|
|
1152
|
+
// the tunnel closes instead of failing flow by flow while it bills.
|
|
1153
|
+
if (tunnelDependent) await cancelCloudTest(id, origin)
|
|
1154
|
+
if (interrupted) break
|
|
1155
|
+
console.error(
|
|
1156
|
+
` error: cloud test timed out after ${timeoutMs < 1000 ? `${timeoutMs}ms` : `${timeoutMs / 1000}s`}`,
|
|
1157
|
+
)
|
|
1158
|
+
return 1
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
summary.push({ label: job.label ?? id, passed: exitCode === 0 })
|
|
1162
|
+
if (exitCode !== 0 && worstExit === 0) worstExit = exitCode
|
|
1163
|
+
if (interrupted) break
|
|
1164
|
+
}
|
|
1165
|
+
if (interrupted) return 130
|
|
1166
|
+
return worstExit
|
|
970
1167
|
} finally {
|
|
1168
|
+
if (tunnelDependent) {
|
|
1169
|
+
process.off('SIGINT', onSignal)
|
|
1170
|
+
process.off('SIGTERM', onSignal)
|
|
1171
|
+
}
|
|
1172
|
+
if (jobs.length > 1 && summary.length > 0) {
|
|
1173
|
+
console.log('\n cloud test summary:')
|
|
1174
|
+
for (const entry of summary) {
|
|
1175
|
+
console.log(` ${entry.passed ? 'pass' : 'fail'} ${entry.label}`)
|
|
1176
|
+
}
|
|
1177
|
+
const passed = summary.filter((entry) => entry.passed).length
|
|
1178
|
+
const stopped =
|
|
1179
|
+
summary.length < jobs.length
|
|
1180
|
+
? ` (stopped after ${summary.length} of ${jobs.length})`
|
|
1181
|
+
: ''
|
|
1182
|
+
console.log(` ${passed}/${summary.length} flows passed${stopped}`)
|
|
1183
|
+
}
|
|
971
1184
|
closeTunnel?.()
|
|
972
1185
|
}
|
|
973
1186
|
}
|