Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions cypress/cypress.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const { defineConfig } = require('cypress')
const { downloadFile } = require('cypress-downloadfile/lib/addPlugin')
const { registerScaleFleetSimulatorTasks } = require('./plugins/scaleFleetSimulatorTasks')

module.exports = defineConfig({
video: true,
Expand All @@ -14,6 +15,7 @@ module.exports = defineConfig({
testIsolation: false,
setupNodeEvents(on, config) {
on('task', { downloadFile })
registerScaleFleetSimulatorTasks(on)
// HTTPS oauth → http://localhost callback needs chromeWebSecurity off for the device-login spec
// only; keep default true for other e2e specs.
const norm = (p) => String(p).replace(/\\/g, '/')
Expand Down
58 changes: 58 additions & 0 deletions cypress/e2e/device.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,62 @@ describe('Device Management', () => {
devicesPage.decommissionDevice()
})
})

describe('Run device simulator to demo 50 devices', () => {
before(() => {
cy.task('scaleFleetSimulatorStart')
cy.task(
'scaleFleetSimulatorWaitForDevices',
{
expected: 50,
labelSelector: 'fleet=scale-fleet-00',
timeoutMs: 660000,
pollMs: 5000,
},
{ timeout: 660000 },
)
})

after(() => {
cy.task('scaleFleetSimulatorStop')
})

it('should list 15 enrolled devices on pages 1–3 and 5 on page 4', () => {
devicesPage.filterByFleetScaleLabel()
//devicesPage.goToFirstEnrolledDevicesPage()

cy.log('Page 1')
devicesPage.expectEnrolledDeviceRowsCount(15)
devicesPage.clickEnrolledDevicesNextPage()
cy.log('Page 2')
devicesPage.expectEnrolledDeviceRowsCount(15)
devicesPage.clickEnrolledDevicesNextPage()
cy.log('Page 3')
devicesPage.expectEnrolledDeviceRowsCount(15)
devicesPage.clickEnrolledDevicesNextPage()
cy.log('Page 4')
devicesPage.expectEnrolledDeviceRowsCount(5)
})

it('after decommissioning one device from page 3, page 3 still has 15 rows and page 4 has 4', () => {
devicesPage.filterByFleetScaleLabel()
devicesPage.goToEnrolledDevicesPageFromFirst(3)

devicesPage.expectEnrolledDeviceRowsCount(15)

devicesPage.decommissionDeviceAtEnrolledRow(0)

cy.get('[data-testid="show-decommissioned-devices-switch"]').closest('label').click()
cy.get('[data-testid="enrolled-devices-table"]', { timeout: 120000 }).should('exist')

devicesPage.filterByFleetScaleLabel()
devicesPage.goToEnrolledDevicesPageFromFirst(3)

devicesPage.expectEnrolledDeviceRowsCount(15)

devicesPage.clickEnrolledDevicesNextPage()

devicesPage.expectEnrolledDeviceRowsCount(4)
})
})
})
1 change: 1 addition & 0 deletions cypress/e2e/fleet.cy.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fleetsPage } from '../views/fleetsPage'
import { repositoriesPage } from '../views/repositoriesPage'

// Skipped temporarily to shorten CI runs — remove .skip to re-enable.
describe('Fleet Management', () => {
before(() => {
cy.ensureLoggedIn()
Expand Down
1 change: 1 addition & 0 deletions cypress/e2e/repository.cy.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { repositoriesPage } from '../views/repositoriesPage'

// Skipped temporarily to shorten CI runs — remove .skip to re-enable.
describe('Repository Management', () => {
before(() => {
cy.ensureLoggedIn()
Expand Down
152 changes: 152 additions & 0 deletions cypress/plugins/scaleFleetSimulatorTasks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
const { spawn, execFileSync } = require('child_process')
const path = require('path')
const os = require('os')

let simulatorProcess = null

/**
* Turns a path that starts with `~/` into an absolute path using the current user’s home directory.
* Other paths are returned unchanged; falsy values are returned as-is.
*/
function expandPath(p) {
if (!p) return p
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2))
return p
}

/**
* Resolves the `flightctl` CLI binary used to list devices during polls.
* Uses `CYPRESS_FLIGHTCTL_BIN` when set, otherwise defaults to `~/flightctl/bin/flightctl`.
*/
function getFlightctlBin() {
const fromEnv = process.env.CYPRESS_FLIGHTCTL_BIN
if (fromEnv) return expandPath(fromEnv)
return path.join(os.homedir(), 'flightctl/bin/flightctl')
}

/**
* Resolves the device simulator executable spawned for the scale demo.
* Uses `CYPRESS_DEVICE_SIMULATOR_BIN` when set, otherwise defaults to `~/flightctl/bin/devicesimulator`.
*/
function getSimulatorBin() {
const fromEnv = process.env.CYPRESS_DEVICE_SIMULATOR_BIN
if (fromEnv) return expandPath(fromEnv)
return path.join(os.homedir(), 'flightctl/bin/devicesimulator')
}

/**
* Async delay helper used between polls so the wait loop does not hammer the API or CLI.
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}

/**
* Runs `flightctl get devices` with a label selector and returns how many devices appear in JSON `items`.
* Tries several flag combinations (`-l` / `--selector`) so minor CLI differences still parse correctly.
*/
function countDevices(flightctlBin, labelSelector) {
const attempts = [
['get', 'devices', '-l', labelSelector, '-o', 'json'],
['get', 'devices', '--selector', labelSelector, '-o', 'json'],
['get', 'devices', '--selector', labelSelector, '--output', 'json'],
]
let lastErr
for (const args of attempts) {
try {
const out = execFileSync(flightctlBin, args, {
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
})
const data = JSON.parse(out)
if (Array.isArray(data.items)) {
return data.items.length
}
} catch (e) {
lastErr = e
}
}
throw new Error(
`Unable to list devices with ${flightctlBin} (selector ${labelSelector}): ${lastErr && lastErr.message}`,
)
}

/**
* Registers Cypress `task` handlers that start/stop the simulator and wait until enough devices exist.
* Call this from `setupNodeEvents` so specs can drive the background process from `cy.task(...)`.
*/
function registerScaleFleetSimulatorTasks(on) {
on('task', {
/**
* Spawns the device simulator with fixed scale-demo arguments (50 devices, fleet label, concurrency 1).
* If a simulator child is already running, returns `{ alreadyRunning: true }` instead of starting another.
*/
scaleFleetSimulatorStart() {
if (simulatorProcess && !simulatorProcess.killed) {
return { alreadyRunning: true, pid: simulatorProcess.pid }
}
const bin = getSimulatorBin()
const args = [
'--count=50',
'--label',
'fleet=scale-fleet-00',
'--log-level',
'error',
'--initial-device-index=0',
'--max-concurrency',
'10',
]
simulatorProcess = spawn(bin, args, { stdio: 'ignore' })
return { pid: simulatorProcess.pid }
},

/**
* Sends SIGTERM to the spawned simulator process and clears the module-level handle.
* Safe to call when nothing is running; subsequent starts create a fresh child process.
*/
scaleFleetSimulatorStop() {
if (simulatorProcess && !simulatorProcess.killed) {
try {
simulatorProcess.kill('SIGTERM')
} catch (_) {
/* ignore */
}
}
simulatorProcess = null
return null
},

/**
* Polls `flightctl get devices` until at least `expected` devices match `labelSelector`, or `timeoutMs` elapses.
* Transient CLI or parse failures are retried on each interval instead of failing the whole wait immediately.
*/
async scaleFleetSimulatorWaitForDevices({
expected = 50,
labelSelector = 'fleet=scale-fleet-00',
timeoutMs = 600000,
pollMs = 5000,
}) {
const flightctlBin = getFlightctlBin()
const deadline = Date.now() + timeoutMs
let last = 0
let lastErr
while (Date.now() < deadline) {
try {
const c = countDevices(flightctlBin, labelSelector)
last = c
if (c >= expected) {
return { count: c }
}
} catch (e) {
lastErr = e
}
await sleep(pollMs)
}
throw new Error(
`Timed out after ${timeoutMs}ms waiting for ${expected} devices (last count: ${last}). ${lastErr ? lastErr.message : ''}`,
)
},
})
}

module.exports = { registerScaleFleetSimulatorTasks }
135 changes: 135 additions & 0 deletions cypress/views/devicesPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,34 @@ const EVENTS_CONTAINER = '[data-testid="device-events-list"]'
/** RichValidationTextField validation button for approve modal alias */
const DEVICE_ALIAS_VALIDATION_BTN = '[data-testid="rich-validation-field-deviceAlias-validation-button"]'

/** Devices scale demo: label applied by devicesimulator (`--label fleet=scale-fleet-00`) */
export const SCALE_FLEET_LABEL_TEXT = 'fleet=scale-fleet-00'

/** Real `<input>` inside PatternFly TextInputGroup (`#typeahead-select-input` is the wrapper div). */
const FLEET_LABEL_TYPEAHEAD_INPUT = '#typeahead-select-input input'

const enrolledDeviceRows = () =>
cy.get('[data-testid="enrolled-devices-table"] tbody tr[data-testid^="enrolled-device-row-"]')

/**
* Closest ancestor of the enrolled table that also contains this list’s pagination (sibling of the
* table in the DOM). Safer than `#devices-toolbar`.parent() when the console wraps the toolbar.
*/
const enrolledDevicesListSection = () =>
cy.get('[data-testid="enrolled-devices-table"]', { timeout: 60000 }).parents().filter((_, el) => {
return Cypress.$(el).find('button[aria-label="Go to next page"]').length > 0
}).first()

/** PatternFly disables pagination while `isUpdating`; wait for spinner to leave the devices paginator. */
const waitEnrolledPaginationIdle = () => {
enrolledDevicesListSection()
.find('.pf-v6-c-pagination')
.first()
.should(($p) => {
expect($p.find('.pf-v6-c-spinner').length).to.eq(0)
}, { timeout: 120000 })
}

/**
* DevicesPage object for device management operations.
* Prefer data-testid selectors from flightctl-ui for stability.
Expand Down Expand Up @@ -185,4 +213,111 @@ export const devicesPage = {
cy.get('[data-testid="device-terminal-panel"]', { timeout: 50000 }).should('be.visible')
cy.get('[data-testid="device-terminal-panel"]').click()
},

/** Leave decommissioned list and show enrolled devices (same switch data-testid on both tables). */
ensureEnrolledDevicesView: () => {
cy.get('body').then(($body) => {
const onDecommissioned = $body.find('[data-testid="show-decommissioned-devices-switch"][aria-checked="true"]')
.length
if (onDecommissioned) {
cy.get('[data-testid="show-decommissioned-devices-switch"]')
.filter('[aria-checked="true"]')
.closest('label')
.click()
}
})
cy.get('[data-testid="enrolled-devices-table"]', { timeout: 60000 }).should('exist')
},

/**
* Filter enrolled devices by label using the “Labels and fleets” typeahead (must match CLI selector).
*/
filterByFleetScaleLabel: () => {
common.navigateTo('Devices')
devicesPage.ensureEnrolledDevicesView()
// Toolbar can sit in overflow:auto regions in ACM/console — avoid visibility flake; interact with force after scroll.
cy.get('#devices-toolbar', { timeout: 30000 }).scrollIntoView()
cy.get(FLEET_LABEL_TYPEAHEAD_INPUT, { timeout: 30000 }).should('exist').scrollIntoView({ block: 'center' })
cy.get(FLEET_LABEL_TYPEAHEAD_INPUT).clear({ force: true })
cy.get(FLEET_LABEL_TYPEAHEAD_INPUT).type(SCALE_FLEET_LABEL_TEXT, { force: true })
// Label options use `hasCheckbox` in the UI → PatternFly uses role="menuitem", not role="option".
cy.wait(1200)
// Match can resolve to more than one node (e.g. hidden + visible popper, or label + row). Click one.
cy.contains('[role="menuitem"], [role="option"]', SCALE_FLEET_LABEL_TEXT, { timeout: 120000 })
.filter(':visible')
.first()
.click({ force: true })
// Close the typeahead panel so it does not stay open and block pagination / table clicks.
// Two ListPages on this screen (enrollment + enrolled) both use data-testid="list-page-title" — click the enrolled one (last in DOM).
cy.get('[data-testid="list-page-title"]').last().should('be.visible').click({ force: true })
cy.get('[data-testid="enrolled-devices-table"]', { timeout: 120000 }).should('exist')
enrolledDeviceRows().should('have.length.at.least', 1)
},

expectEnrolledDeviceRowsCount: (expected) => {
enrolledDeviceRows().should('have.length', expected)
},

/** “Devices” table pagination only (scoped to enrolled list; waits out API refresh disabling controls). */
clickEnrolledDevicesNextPage: () => {
cy.get('[data-testid="enrolled-devices-table"]', { timeout: 60000 }).should('exist')
cy.get('[data-testid="enrolled-devices-table"]').scrollIntoView({ block: 'start' })
enrolledDeviceRows().should('have.length.at.least', 1)
enrolledDeviceRows().last().scrollIntoView({ block: 'end' })
waitEnrolledPaginationIdle()
enrolledDevicesListSection().within(() => {
cy.get('button[aria-label="Go to next page"]', { timeout: 120000 })
.first()
.scrollIntoView({ block: 'center', inline: 'center' })
.should('not.be.disabled')
.click({ force: true })
})
},

/**
* Return to page 1 of the enrolled-devices paginator. Compact PatternFly often omits “Go to first page”,
* so we click “Go to previous page” until it is disabled (same device-table paginator index as next/previous).
*/
goToFirstEnrolledDevicesPage: () => {
cy.get('[data-testid="enrolled-devices-table"]', { timeout: 60000 }).should('exist')
cy.get('[data-testid="enrolled-devices-table"]').scrollIntoView({ block: 'start' })
enrolledDeviceRows().last().scrollIntoView({ block: 'end' })
waitEnrolledPaginationIdle()
cy.wrap(Array.from({ length: 12 })).each(() => {
waitEnrolledPaginationIdle()
enrolledDevicesListSection().within(() => {
cy.get('button[aria-label="Go to previous page"]', { timeout: 120000 })
.first()
.then(($prev) => {
if (!$prev.is(':disabled')) {
cy.wrap($prev).scrollIntoView({ block: 'center' }).click({ force: true })
}
})
})
})
waitEnrolledPaginationIdle()
enrolledDevicesListSection().within(() => {
cy.get('button[aria-label="Go to previous page"]', { timeout: 120000 })
.first()
.should('be.disabled')
})
},

goToEnrolledDevicesPageFromFirst: (pageNum) => {
devicesPage.goToFirstEnrolledDevicesPage()
for (let p = 1; p < pageNum; p++) {
devicesPage.clickEnrolledDevicesNextPage()
}
},

decommissionDeviceAtEnrolledRow: (rowIndex = 0) => {
cy.get(`[data-testid="enrolled-device-row-${rowIndex}"]`)
.find(`[data-testid^="device-row-actions-"] .pf-v6-c-menu-toggle`)
.click()
cy.contains('[role="menuitem"]', 'Decommission device').click()
cy.get('.pf-v6-c-modal-box').within(() => {
cy.contains('button.pf-m-danger', 'Decommission device').click()
})
cy.get('[data-testid="decommissioned-devices-table"]', { timeout: 120000 }).should('exist')
},
}