Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
1 change: 1 addition & 0 deletions cypress.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:1881',
specPattern: 'cypress/tests/**/*.spec.{js,jsx,ts,tsx}',
retries: { runMode: 2, openMode: 0 },
setupNodeEvents (on, config) {
// implement node event listeners here
}
Expand Down
2 changes: 1 addition & 1 deletion cypress/fixtures/flows/context-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"type": "function",
"z": "node-red-tab-helper-api",
"name": "function 2",
"func": "global.set('msg', undefined)\nreturn msg;",
"func": "global.set('msg', undefined)\nglobal.set('connectTopic', undefined)\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
Expand Down
26 changes: 25 additions & 1 deletion cypress/fixtures/flows/dashboard-buttons.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,34 @@
"y": 60,
"wires": [
[
"dashboard-ui-button-bool"
"dashboard-ui-button-bool",
"connect-marker"
]
]
},
{
"id": "connect-marker",
"type": "change",
"z": "node-red-tab-buttons",
"name": "ui-control connect marker",
"rules": [
{
"t": "set",
"p": "connectTopic",
"pt": "global",
"to": "topic",
"tot": "msg"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 80,
"y": 100,
"wires": [[]]
},
{
"id": "dashboard-ui-button-bool",
"type": "ui-button",
Expand Down
28 changes: 26 additions & 2 deletions cypress/fixtures/flows/dashboard-forms.json
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@
"z": "node-red-tab-forms",
"name": "",
"ui": "dashboard-ui-base",
"events": "all",
"events": "connect",
"x": 120,
"y": 640,
"wires": [
Expand Down Expand Up @@ -751,10 +751,34 @@
"2ef2b0e5846b5fd1",
"31287d1f6fa8a0b7",
"f79478230b25922a",
"68ecd321adc91b99"
"68ecd321adc91b99",
"forms-connect-marker"
]
]
},
{
"id": "forms-connect-marker",
"type": "change",
"z": "node-red-tab-forms",
"name": "ui-control connect marker",
"rules": [
{
"t": "set",
"p": "connectTopic",
"pt": "global",
"to": "forms-connect-ready",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 75,
"y": 340,
"wires": [[]]
},
{
"id": "bcf9325de95813c3",
"type": "delay",
Expand Down
26 changes: 23 additions & 3 deletions cypress/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,29 @@ Cypress.Commands.add('resetContext', () => {
cy.request('POST', '/context/reset')
})

Cypress.Commands.add('checkOutput', (key, value, comparator = 'eq') => {
const parentKey = key.split('.')[0]
cy.request('GET', '/context/flow?key=' + parentKey).its(`body.${key}`).should(comparator, value)
Cypress.Commands.add('checkOutput', (key, value, comparator = 'eq', { timeout = 4000, interval = 100 } = {}) => {
// Polls /context/flow, re-issuing the request each attempt until the assertion passes or the timeout expires.
// Plain cy.request().its().should() only retries the assertion against the original response body, so a value
// that arrives after the first fetch would never be observed.
const tokenise = (path) => path.match(/[^.[\]]+/g) || []
const parentKey = tokenise(key)[0]
const getNested = (obj, path) => tokenise(path).reduce((acc, k) => (acc == null ? acc : acc[k]), obj)
const matches = (actual) => comparator === 'not.eq' ? actual !== value : actual === value
const deadline = Date.now() + timeout
const attempt = () => {
return cy.request({ method: 'GET', url: '/context/flow?key=' + parentKey, log: false }).then((res) => {
const actual = getNested(res.body, key)
if (matches(actual)) {
return actual
}
if (Date.now() >= deadline) {
throw new Error(`checkOutput timed out after ${timeout}ms: expected '${key}' ${comparator} ${JSON.stringify(value)}, got ${JSON.stringify(actual)}`)
}
cy.wait(interval, { log: false })
return attempt()
})
}
return attempt()
})

Cypress.Commands.add('setGlobalVar', (key, value) => {
Expand Down
11 changes: 11 additions & 0 deletions cypress/tests/widgets/button.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
describe('Node-RED Dashboard 2.0 - Buttons', () => {
beforeEach(() => {
cy.deployFixture('dashboard-buttons')
// Clear sentinel context keys (like connectTopic) before the dashboard page loads,
// so the barrier in the Button 1 (bool) test waits for THIS test's connect-event
// rather than passing immediately on stale data from a previous test run.
cy.resetContext()
cy.visit('/dashboard/page1')
})

Expand All @@ -13,6 +17,13 @@ describe('Node-RED Dashboard 2.0 - Buttons', () => {
})

it('Button 1 (bool) outputs a bool payload & topic from msg.topic', () => {
// Wait for the ui-control "connect" event to have propagated through change-1
// and seeded msg.topic on the button. Without this barrier, the click can race
// the WS-driven re-render and the button emits with an empty topic.
// The connect-marker node writes msg.topic into flow.connectTopic on every ui-control
// event, deliberately separate from the test-helper's global.msg so subsequent
// ui-control events don't clobber the button-click msg we assert on below.
cy.checkOutput('connectTopic', 'msg.topic from inject-1')
// Emitting Bool
cy.clickAndWait(cy.get('button').contains('Button 1 (bool)'))
cy.checkOutput('msg.payload', true)
Expand Down
64 changes: 46 additions & 18 deletions cypress/tests/widgets/form.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
describe('Node-RED Dashboard 2.0 - Forms', () => {
beforeEach(() => {
cy.deployFixture('dashboard-forms')
// Clear sentinel context keys (like connectTopic) before the dashboard page loads,
// so the per-test barrier waits for THIS test's connect-event rather than
// passing immediately on stale data from a previous test run.
cy.resetContext()
cy.visit('/dashboard/page1')
})

Expand All @@ -9,18 +13,31 @@ describe('Node-RED Dashboard 2.0 - Forms', () => {
})

it('blurring a required field runs validation', () => {
cy.contains('Name is required').should('not.exist')
cy.clickAndWait(cy.get('[data-form="form-row-name"]'), 200)
cy.get('[data-form="form-row-name"]').find('input[type="text"]').focus()

// blur the text input
// Deterministic barrier: wait for the ui-control connect-event chain to have
// propagated to the forms tab. Without this, Vuetify can be mid-re-render when
// we focus/blur and the validator gets skipped because the input is transiently disabled.
cy.checkOutput('connectTopic', 'forms-connect-ready')
// Scope assertions to the Test Form widget — the connect-event chain sends
// payload:'connect' to form1 which inadvertently triggers its validation,
// so an unscoped cy.contains() would pick up form1's "Name is required" text.
cy.get('#nrdb-ui-widget-dashboard-ui-form').within(() => {
cy.contains('Name is required').should('not.exist')
})
cy.get('[data-form="form-row-name"] input[type="text"]').should('be.visible').and('not.be.disabled')
// Click the input directly (rather than the wrapper + .focus()) so Vuetify's
// "touched" state is set reliably; then blur the focused element to fire validators.
cy.get('[data-form="form-row-name"]').find('input[type="text"]').click({ force: true })
cy.focused().blur()

cy.contains('Name is required').should('be.visible')
cy.get('#nrdb-ui-widget-dashboard-ui-form').within(() => {
cy.contains('Name is required').should('be.visible')
})
})

it('enables the submit button once required fields are completed', () => {
cy.contains('Name is required').should('not.exist')
cy.get('#nrdb-ui-widget-dashboard-ui-form').within(() => {
cy.contains('Name is required').should('not.exist')
})

// need to click first to allow for Vuetify's animation of label
// cy.get('[data-form="form-row-name"]').click()
Expand All @@ -43,6 +60,10 @@ describe('Node-RED Dashboard 2.0 - Forms', () => {
})

it('can have their content defined by msg.ui_update.options', () => {
// Deterministic barrier: the ui-control connect-event chain also re-sets the dynamic
// form's options to a minimal set. Wait for that to complete first, otherwise it can
// arrive AFTER the override click and overwrite the full option set we're testing.
cy.checkOutput('connectTopic', 'forms-connect-ready')
cy.get('#nrdb-ui-widget-dashboard-ui-form-dynamic').find('[data-form="form-row-name"]').should('not.exist')
cy.get('#nrdb-ui-widget-dashboard-ui-form-dynamic').find('[data-form="form-row-multiline"]').should('not.exist')
cy.get('#nrdb-ui-widget-dashboard-ui-form-dynamic').find('[data-form="form-row-password"]').should('not.exist')
Expand Down Expand Up @@ -70,44 +91,51 @@ describe('Node-RED Dashboard 2.0 - Forms', () => {
const topicElId = '#nrdb-ui-widget-d31f09b33f18c5db'

it('Delivers topic from msg.topic', () => {
// Deterministic barrier: wait for connect-event chain to complete before interacting
cy.checkOutput('connectTopic', 'forms-connect-ready')
const formElId = '#nrdb-ui-widget-3cd8df20415c4c04'
// wait for the input to be actionable
cy.get(formElId).find('[data-form="form-row-name1"] input[type="text"]').should('not.be.disabled')
// enter a value into the text input field
cy.get(formElId).find('[data-form="form-row-name1"] input[type="text"]').clear()
cy.get(formElId).find('[data-form="form-row-name1"] input[type="text"]').clear({ force: true })
cy.get(formElId).find('[data-form="form-row-name1"] input[type="text"]').should('have.value', '')
cy.get(formElId).find('[data-form="form-row-name1"] input[type="text"]').type('payload for msg.topic test')
// submit the form
cy.clickAndWait(cy.get(formElId).find('[data-action="form-submit"]'), 200)
cy.get(formElId).find('[data-form="form-row-name1"] input[type="text"]').type('payload for msg.topic test', { force: true })
Comment thread
cstns marked this conversation as resolved.
// submit the form; force-click to bypass transient disabled state from connect-event re-renders
cy.get(formElId).find('[data-action="form-submit"]').click({ force: true })
// check the output for the topic
cy.get(payloadElId).find('.nrdb-ui-text-value').contains('{"name1":"payload for msg.topic test"}')
cy.get(topicElId).find('.nrdb-ui-text-value').contains('topic from msg.topic')
})

it('Delivers topic from flow.f1', () => {
// Deterministic barrier: wait for connect-event chain to complete before interacting
cy.checkOutput('connectTopic', 'forms-connect-ready')
const formElId = '#nrdb-ui-widget-ddb5a30c677e5e0b'
// wait for the input to be actionable
cy.get(formElId).find('[data-form="form-row-name2"] input[type="text"]').should('not.be.disabled')
// enter a value into the text input field
cy.get(formElId).find('[data-form="form-row-name2"] input[type="text"]').clear()
cy.get(formElId).find('[data-form="form-row-name2"] input[type="text"]').clear({ force: true })
cy.get(formElId).find('[data-form="form-row-name2"] input[type="text"]').should('have.value', '')
cy.get(formElId).find('[data-form="form-row-name2"] input[type="text"]').should('not.be.disabled')
cy.get(formElId).find('[data-form="form-row-name2"] input[type="text"]').type('flow.f1 test')
// submit the form
cy.clickAndWait(cy.get(formElId).find('[data-action="form-submit"]'), 200)
cy.get(formElId).find('[data-form="form-row-name2"] input[type="text"]').type('flow.f1 test', { force: true })
// submit the form; force-click to bypass transient disabled state from connect-event re-renders
cy.get(formElId).find('[data-action="form-submit"]').click({ force: true })
// check the output for the topic
cy.get(payloadElId).find('.nrdb-ui-text-value').contains('{"name2":"flow.f1 test"}')
cy.get(topicElId).find('.nrdb-ui-text-value').contains('topic from flow.f1')
})

it('Delivers topic from global.g1', () => {
// Deterministic barrier: wait for connect-event chain to complete before interacting
cy.checkOutput('connectTopic', 'forms-connect-ready')
const formElId = '#nrdb-ui-widget-cf0774a3c2e9edd4'
// wait for the input to be actionable
cy.get(formElId).find('[data-form="form-row-name3"] input[type="text"]').should('not.be.disabled')
// enter a value into the text input field
cy.get(formElId).find('[data-form="form-row-name3"] input[type="text"]').type('global.g1 test')
// submit the form
cy.clickAndWait(cy.get(formElId).find('[data-action="form-submit"]'), 200)
cy.get(formElId).find('[data-form="form-row-name3"] input[type="text"]').clear({ force: true })
cy.get(formElId).find('[data-form="form-row-name3"] input[type="text"]').type('global.g1 test', { force: true })
// submit the form; force-click to bypass transient disabled state from connect-event re-renders
cy.get(formElId).find('[data-action="form-submit"]').click({ force: true })
// check the output for the topic
cy.get(payloadElId).find('.nrdb-ui-text-value').contains('{"name3":"global.g1 test"}')
cy.get(topicElId).find('.nrdb-ui-text-value').contains('topic from global.g1')
Expand Down
9 changes: 6 additions & 3 deletions cypress/tests/widgets/markdown.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,20 @@ describe('Node-RED Dashboard 2.0 - Markdown', () => {

// this markdown node has a full mermaid chart defined on msg.payload
it('allow for full mermaid charts to be defined by msg', () => {
// Mermaid renders asynchronously and can take longer than Cypress's 4s default on slower
// or busy systems, so extend the timeout on the SVG-node assertions specifically.
const MERMAID_TIMEOUT = 30000
cy.clickAndWait(cy.get('#nrdb-ui-widget-dashboard-ui-button-graph-E'))
cy.get('#nrdb-ui-widget-dashboard-ui-markdown-2').find('.nodes .node').should('have.length', 5)
cy.get('#nrdb-ui-widget-dashboard-ui-markdown-2').find('.nodes .node', { timeout: MERMAID_TIMEOUT }).should('have.length', 5)
cy.get('#nrdb-ui-widget-dashboard-ui-markdown-2').find('.nodes .node').eq(4).should('have.text', 'E')

cy.clickAndWait(cy.get('#nrdb-ui-widget-dashboard-ui-button-graph-F'))
cy.get('#nrdb-ui-widget-dashboard-ui-markdown-2').find('.nodes .node').should('have.length', 5)
cy.get('#nrdb-ui-widget-dashboard-ui-markdown-2').find('.nodes .node', { timeout: MERMAID_TIMEOUT }).should('have.length', 5)
cy.get('#nrdb-ui-widget-dashboard-ui-markdown-2').find('.nodes .node').eq(4).should('have.text', 'F')

cy.reloadDashboard()

cy.get('#nrdb-ui-widget-dashboard-ui-markdown-2').find('.nodes .node').should('have.length', 5)
cy.get('#nrdb-ui-widget-dashboard-ui-markdown-2').find('.nodes .node', { timeout: MERMAID_TIMEOUT }).should('have.length', 5)
cy.get('#nrdb-ui-widget-dashboard-ui-markdown-2').find('.nodes .node').eq(4).should('have.text', 'F')
})
})
5 changes: 3 additions & 2 deletions cypress/tests/widgets/template.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ describe('Node-RED Dashboard 2.0 - Templates (Single Page CSS)', () => {
cy.visit('/dashboard/page1')
cy.get('body').should('have.css', 'background-color', 'rgb(0, 0, 0)')

cy.clickAndWait(cy.get('[data-nav="dashboard-ui-page-2"]'))
cy.url().should('include', '/dashboard/page2')
cy.get('[data-nav="dashboard-ui-page-2"]').should('be.visible')
cy.get('[data-nav="dashboard-ui-page-2"]').click({ force: true })
cy.location('pathname').should('include', '/dashboard/page2')
cy.get('body').should('not.have.css', 'background-color', 'rgb(0, 0, 0)')
})

Expand Down
2 changes: 1 addition & 1 deletion ui/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export default {
const dashboards = Object.keys(this.dashboards)
const id = dashboards.length ? dashboards[0] : undefined
const dashboard = this.dashboards[id]
if (dashboard.allowInstall) {
if (dashboard?.allowInstall) {
this.installable = true
links.push({
rel: 'manifest',
Expand Down