Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions jest/functional/maestro-flow-integrity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as fs from 'fs'
import * as path from 'path'

/**
* Guards the Maestro suite against broken flow references.
*
* flow-smoke.yaml once referenced maestro/tests/*.yaml files that had been
* deleted in a refactor, which made the smoke suite fail instantly in CI.
* Maestro only resolves runFlow targets at runtime, so without this check a
* dangling reference is invisible until an emulator run.
*/

const MAESTRO_ROOT = path.join(__dirname, '..', '..', 'maestro')

const collectYamlFiles = (dir: string): string[] =>
fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) return collectYamlFiles(fullPath)
return /\.ya?ml$/.test(entry.name) ? [fullPath] : []
})

/**
* Extracts flow file references from a Maestro YAML file. Handles both the
* inline form (`- runFlow: some/flow.yaml`) and the expanded form where the
* target sits under a `file:` key (`- runFlow:` / ` file: some/flow.yaml`).
*/
const extractFlowRefs = (yamlPath: string): { ref: string; line: number }[] => {
const refPattern = /^\s*(?:-\s*)?(?:runFlow|file):\s*(['"]?)([^\s'"]+\.ya?ml)\1\s*$/
return fs
.readFileSync(yamlPath, 'utf-8')
.split('\n')
.map((text, index) => {
const match = text.match(refPattern)
return match ? { ref: match[2], line: index + 1 } : undefined
})
.filter((entry): entry is { ref: string; line: number } => entry !== undefined)
}

describe('maestro flow integrity', () => {
const yamlFiles = collectYamlFiles(MAESTRO_ROOT)

it('finds maestro flow files', () => {
expect(yamlFiles.length).toBeGreaterThan(0)
})

it('every runFlow reference points to a file that exists', () => {
const brokenRefs = yamlFiles.flatMap((yamlFile) =>
extractFlowRefs(yamlFile)
.filter(({ ref }) => !fs.existsSync(path.resolve(path.dirname(yamlFile), ref)))
.map(
({ ref, line }) =>
`${path.relative(MAESTRO_ROOT, yamlFile)}:${line} → ${ref} (missing)`,
),
)

expect(brokenRefs).toEqual([])
})

it('the CI entry point flows exist', () => {
expect(fs.existsSync(path.join(MAESTRO_ROOT, 'flow-full.yaml'))).toBe(true)
expect(fs.existsSync(path.join(MAESTRO_ROOT, 'flow-smoke.yaml'))).toBe(true)
})
})
14 changes: 12 additions & 2 deletions maestro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ maestro/

## How it works

The top-level flow files (`flow-full.yaml`, `flow-smoke.yaml`) are the entry points. They call `runFlow` on each `tests/*/flow.yaml` in order. Each `flow.yaml` is responsible for a logical area of the app (setup, home, library, etc.) and in turn calls out to the individual test files within its directory to keep logical groups small and focused.
The top-level flow files (`flow-full.yaml`, `flow-smoke.yaml`) are the entry points. They call `runFlow` on each `flows/*/flow.yaml` in order. Each `flow.yaml` is responsible for a logical area of the app (setup, home, library, etc.) and in turn calls out to the individual test files within its directory to keep logical groups small and focused.

For example, `flows/home/flow.yaml` navigates to the home screen and then delegates to `recently-played.yaml` and any other home-specific tests. This keeps individual test files small while the `flow.yaml` files act as coordinators for their feature area.

Expand All @@ -44,7 +44,17 @@ Runs the complete test suite in sequence:

### `flow-smoke.yaml`

A fast subset intended as a CI gate. Covers login → library browse → album detail → search → settings. Designed to run quickly to catch obvious regressions before a full suite run.
A fast subset intended as a CI gate. Reuses the same `flows/` building blocks as the full suite and covers login → home (recently played → album detail → playback) → search → artist detail. Designed to run quickly to catch obvious regressions before a full suite run.

### Flow hygiene

Because tabs in Jellify keep their own navigation stacks, a flow that pushes a
detail screen inside a tab **must pop back to that tab's root before moving
on** — otherwise the next flow that selects the tab lands on the leftover
detail screen instead of the tab root, and selectors like `search-input`
simply do not exist in the hierarchy. Flows that enter the Search tab should
also start with the defensive reset used in `flows/search/gd-search.yaml`
(scroll up, then bounded back-presses re-selecting the tab between presses).

## Running locally

Expand Down
27 changes: 10 additions & 17 deletions maestro/flow-smoke.yaml
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
appId: com.cosmonautical.jellify
---
# Smoke test flow — fast CI gate (~2 min)
# Covers: login → library browse → album detail → search → settings
# Full regression (flow-0.yaml) runs on nightly schedule
# Smoke test flow — fast CI gate
# Covers: login → home (recently played → album → playback) → search → artist detail
# Full regression (flow-full.yaml) runs on the nightly schedule and via workflow_dispatch.

- clearState
# Setup clears state, launches the app, handles permission dialogs,
# logs in, and selects a server and library
- runFlow: flows/setup/flow.yaml

# Login flow
- runFlow: tests/1-login.yaml
# Home: recently played → album detail → start playback → miniplayer visible
- runFlow: flows/home/flow.yaml

# Quick library check
- runFlow: tests/2-library.yaml

# Album detail (verifies indexed testIDs work)
- runFlow: tests/8-album.yaml

# Search (verifies API connectivity)
- runFlow: tests/3-search.yaml

# Settings and sign out
- runFlow: tests/7-settings.yaml
# Search: Grateful Dead lookup → artist detail (verifies API connectivity + navigation)
- runFlow: flows/search/flow.yaml
7 changes: 0 additions & 7 deletions maestro/flows/flow-settings.yaml

This file was deleted.

43 changes: 38 additions & 5 deletions maestro/flows/quick-actions/flow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,12 @@ appId: com.cosmonautical.jellify
# Wait for search screen
- waitForAnimationToEnd

# Type a search query
# Focus the search input by testID — its placeholder text is randomized, so a
# text match on "Search" would hit the tab bar label instead of the input.
- tapOn:
text: "Search"
id: "search-input"

- eraseText

- inputText: "music"

Expand Down Expand Up @@ -216,11 +219,41 @@ appId: com.cosmonautical.jellify
id: "quick-action-right-0"
optional: true

# If actions appeared, close them
- tapOn:
point: 50%, 40%
# Close any open swipe menu by scrolling — the app closes swipeable rows when
# a scroll begins. A blind coordinate tap here can land on a result row and
# push a detail screen onto the Search stack, which breaks later search flows.
- scroll

- waitForAnimationToEnd

# Restore the Search tab to a clean state for later flows: scroll back up to
# the input first, then pop any detail screen that may have been pushed,
# re-selecting the tab after each back-press so this loop can never back out
# of the app.
- scrollUntilVisible:
element:
id: "search-input"
direction: UP
timeout: 20000
optional: true
Comment on lines +233 to 238

- repeat:
times: 3
while:
notVisible:
id: "search-input"
commands:
- pressKey: BACK
- waitForAnimationToEnd
- tapOn:
id: "search-tab-button"
- waitForAnimationToEnd

# Fail loudly here if the Search tab could not be restored — otherwise the
# poisoned stack would surface later as a confusing failure in the search flow.
- assertVisible:
id: "search-input"

# Return to home
- tapOn:
id: "home-tab-button"
Expand Down
37 changes: 35 additions & 2 deletions maestro/flows/search/gd-search.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,45 @@
appId: com.cosmonautical.jellify
---

# Quick actions may have brought up some search results already, so scroll to the top of the search tab to ensure we're in a consistent state
# The Search tab may not be showing its root when we arrive: earlier flows can
# leave detail screens (album/artist) pushed on this tab's stack, or leave the
# root list scrolled away from the search input. A pushed screen removes
# search-input from the hierarchy entirely, so scrolling alone can never
# recover it. Recover deterministically instead:

# 1) If the root is just scrolled down, bring the input back into view.
- scrollUntilVisible:
element:
id: "search-input"
direction: UP
timeout: 180000
timeout: 20000
optional: true
Comment on lines 12 to +16

# 2) If it's still missing, a detail screen is covering the root — pop it.
# Re-select the tab after every back-press so this loop can never back out
# of the app, whichever tab back navigation lands on.
- repeat:
times: 3
while:
notVisible:
id: "search-input"
commands:
- pressKey: BACK
- waitForAnimationToEnd
- tapOn:
id: "search-tab-button"
- waitForAnimationToEnd

# 3) The restored root may itself still be scrolled — final tidy-up.
- scrollUntilVisible:
element:
id: "search-input"
direction: UP
timeout: 20000
optional: true
Comment on lines +34 to +39

- assertVisible:
id: "search-input"

# Tap on the search input to focus it and bring up the keyboard
- tapOn:
Expand Down
Loading
Loading