fix: prefix form arguments to prevent collisions with other page elem… (#1072)
This commit is contained in:
commit
ee97906808
|
|
@ -10,7 +10,7 @@
|
||||||
"stylelint-config-standard": "^40.0.0"
|
"stylelint-config-standard": "^40.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test resources/vue/components/*.test.mjs"
|
"test": "node --test resources/vue/components/*.test.mjs resources/vue/utils/*.test.mjs"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"parcelIgnore": [
|
"parcelIgnore": [
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="choice-checklist" :id="`${id}-wrapper`">
|
<div class="choice-checklist" :id="wrapperId">
|
||||||
<div class="choice-checklist-controls">
|
<div class="choice-checklist-controls">
|
||||||
<button type="button" class="choice-checklist-control" @click="selectAll">
|
<button type="button" class="choice-checklist-control" @click="selectAll">
|
||||||
Select all
|
Select all
|
||||||
|
|
@ -14,10 +14,10 @@
|
||||||
v-for="(choice, index) in choices"
|
v-for="(choice, index) in choices"
|
||||||
:key="choice.value"
|
:key="choice.value"
|
||||||
class="choice-checklist-item"
|
class="choice-checklist-item"
|
||||||
:for="`${id}-${index}`"
|
:for="optionId(index)"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
:id="`${id}-${index}`"
|
:id="optionId(index)"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="isSelected(choice.value)"
|
:checked="isSelected(choice.value)"
|
||||||
@change="handleToggle(choice.value)"
|
@change="handleToggle(choice.value)"
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<input
|
<input
|
||||||
:id="`${id}-value`"
|
:id="valueId"
|
||||||
:name="name"
|
:name="name"
|
||||||
type="text"
|
type="text"
|
||||||
class="visually-hidden choice-checklist-value"
|
class="visually-hidden choice-checklist-value"
|
||||||
|
|
@ -47,12 +47,13 @@ import {
|
||||||
parseChecklistValue,
|
parseChecklistValue,
|
||||||
toggleChoice
|
toggleChoice
|
||||||
} from '../utils/choiceChecklistHelpers.js'
|
} from '../utils/choiceChecklistHelpers.js'
|
||||||
|
import {
|
||||||
|
argumentFieldOptionId,
|
||||||
|
argumentFieldValueId,
|
||||||
|
argumentFieldWrapperId
|
||||||
|
} from '../utils/argumentFieldIds.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
name: {
|
name: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
|
@ -78,6 +79,12 @@ const props = defineProps({
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
const selectedValues = computed(() => parseChecklistValue(props.modelValue))
|
const selectedValues = computed(() => parseChecklistValue(props.modelValue))
|
||||||
|
const wrapperId = computed(() => argumentFieldWrapperId(props.name))
|
||||||
|
const valueId = computed(() => argumentFieldValueId(props.name))
|
||||||
|
|
||||||
|
function optionId (index) {
|
||||||
|
return argumentFieldOptionId(props.name, index)
|
||||||
|
}
|
||||||
|
|
||||||
function isSelected(value) {
|
function isSelected(value) {
|
||||||
return selectedValues.value.includes(value)
|
return selectedValues.value.includes(value)
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="(choice, index) in filteredChoices"
|
v-for="(choice, index) in filteredChoices"
|
||||||
:id="`${listboxId}-option-${index}`"
|
:id="listboxOptionId(index)"
|
||||||
:key="choice.value"
|
:key="choice.value"
|
||||||
role="option"
|
role="option"
|
||||||
:aria-selected="choice.value === modelValue"
|
:aria-selected="choice.value === modelValue"
|
||||||
|
|
@ -56,6 +56,10 @@ import {
|
||||||
choiceDisplayLabel,
|
choiceDisplayLabel,
|
||||||
syncStateFromModelValue
|
syncStateFromModelValue
|
||||||
} from './choiceComboboxHelpers.js'
|
} from './choiceComboboxHelpers.js'
|
||||||
|
import {
|
||||||
|
argumentFieldListboxId,
|
||||||
|
argumentFieldListboxOptionId
|
||||||
|
} from '../utils/argumentFieldIds.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
|
|
@ -91,16 +95,20 @@ const query = ref('')
|
||||||
const isUserFiltering = ref(false)
|
const isUserFiltering = ref(false)
|
||||||
const highlightedIndex = ref(0)
|
const highlightedIndex = ref(0)
|
||||||
|
|
||||||
const listboxId = computed(() => `${props.id}-listbox`)
|
const listboxId = computed(() => argumentFieldListboxId(props.name))
|
||||||
|
|
||||||
const activeDescendantId = computed(() => {
|
const activeDescendantId = computed(() => {
|
||||||
if (!isOpen.value || filteredChoices.value.length === 0) {
|
if (!isOpen.value || filteredChoices.value.length === 0) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${listboxId.value}-option-${highlightedIndex.value}`
|
return listboxOptionId(highlightedIndex.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function listboxOptionId (index) {
|
||||||
|
return argumentFieldListboxOptionId(props.name, index)
|
||||||
|
}
|
||||||
|
|
||||||
const placeholderText = computed(() => {
|
const placeholderText = computed(() => {
|
||||||
if (props.required) {
|
if (props.required) {
|
||||||
return 'Search and select...'
|
return 'Search and select...'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Role-prefixed IDs keep related elements from colliding when argument names
|
||||||
|
// contain another argument's name plus a former suffix (e.g. "foo" vs "foo-choices").
|
||||||
|
const ARGUMENT_ID_NAMESPACE = 'arg-'
|
||||||
|
|
||||||
|
export const ARGUMENT_FIELD_ID_PREFIX = `${ARGUMENT_ID_NAMESPACE}field-`
|
||||||
|
|
||||||
|
export function argumentFieldId (argumentName) {
|
||||||
|
return `${ARGUMENT_FIELD_ID_PREFIX}${argumentName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function argumentFieldChoicesId (argumentName) {
|
||||||
|
return `${ARGUMENT_ID_NAMESPACE}choices-${argumentName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function argumentFieldValueId (argumentName) {
|
||||||
|
return `${ARGUMENT_ID_NAMESPACE}value-${argumentName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function argumentFieldWrapperId (argumentName) {
|
||||||
|
return `${ARGUMENT_ID_NAMESPACE}wrapper-${argumentName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function argumentFieldOptionId (argumentName, index) {
|
||||||
|
return `${ARGUMENT_ID_NAMESPACE}option-${argumentName}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function argumentFieldListboxId (argumentName) {
|
||||||
|
return `${ARGUMENT_ID_NAMESPACE}listbox-${argumentName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function argumentFieldListboxOptionId (argumentName, index) {
|
||||||
|
return `${ARGUMENT_ID_NAMESPACE}listbox-option-${argumentName}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function argumentFieldValidationElementId (argument) {
|
||||||
|
if (argument.type === 'checklist') {
|
||||||
|
return argumentFieldValueId(argument.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return argumentFieldId(argument.name)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
ARGUMENT_FIELD_ID_PREFIX,
|
||||||
|
argumentFieldChoicesId,
|
||||||
|
argumentFieldId,
|
||||||
|
argumentFieldListboxId,
|
||||||
|
argumentFieldListboxOptionId,
|
||||||
|
argumentFieldOptionId,
|
||||||
|
argumentFieldValidationElementId,
|
||||||
|
argumentFieldValueId,
|
||||||
|
argumentFieldWrapperId
|
||||||
|
} from './argumentFieldIds.js'
|
||||||
|
|
||||||
|
test('argumentFieldId namespaces argument names with a field role', () => {
|
||||||
|
assert.equal(argumentFieldId('content'), 'arg-field-content')
|
||||||
|
assert.equal(argumentFieldId('banner'), 'arg-field-banner')
|
||||||
|
assert.equal(argumentFieldId('confirm'), 'arg-field-confirm')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('role-prefixed ids avoid collisions between related argument elements', () => {
|
||||||
|
assert.notEqual(argumentFieldId('content'), argumentFieldChoicesId('content'))
|
||||||
|
assert.notEqual(argumentFieldId('content-choices'), argumentFieldChoicesId('content'))
|
||||||
|
assert.notEqual(argumentFieldId('segments-value'), argumentFieldValueId('segments'))
|
||||||
|
assert.notEqual(argumentFieldId('segments-wrapper'), argumentFieldWrapperId('segments'))
|
||||||
|
assert.notEqual(argumentFieldId('segments-0'), argumentFieldOptionId('segments', 0))
|
||||||
|
assert.notEqual(argumentFieldId('host-listbox'), argumentFieldListboxId('host'))
|
||||||
|
assert.notEqual(
|
||||||
|
argumentFieldId('host-listbox-option-0'),
|
||||||
|
argumentFieldListboxOptionId('host', 0)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('argumentFieldChoicesId uses a distinct choices role', () => {
|
||||||
|
assert.equal(argumentFieldChoicesId('content'), 'arg-choices-content')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('argumentFieldValidationElementId uses checklist value id', () => {
|
||||||
|
assert.equal(
|
||||||
|
argumentFieldValidationElementId({ name: 'segments', type: 'checklist' }),
|
||||||
|
'arg-value-segments'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('argumentFieldValidationElementId uses field id for other argument types', () => {
|
||||||
|
assert.equal(
|
||||||
|
argumentFieldValidationElementId({ name: 'content', type: 'raw_string_multiline' }),
|
||||||
|
'arg-field-content'
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
argumentFieldValidationElementId({ name: 'datetime', type: 'datetime' }),
|
||||||
|
'arg-field-datetime'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('namespaced ids avoid known app-shell element ids', () => {
|
||||||
|
const appShellIds = [
|
||||||
|
'content',
|
||||||
|
'banner',
|
||||||
|
'layout',
|
||||||
|
'mainnav',
|
||||||
|
'app',
|
||||||
|
'big-error',
|
||||||
|
'available-version',
|
||||||
|
'link-login',
|
||||||
|
'username-text',
|
||||||
|
'theme-style',
|
||||||
|
'olivetin-custom-js',
|
||||||
|
'justification',
|
||||||
|
'username',
|
||||||
|
'password',
|
||||||
|
'connection-banner',
|
||||||
|
'execution-results-popup',
|
||||||
|
'logs-filter-suggestions',
|
||||||
|
'argument-popup'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const shellId of appShellIds) {
|
||||||
|
assert.notEqual(argumentFieldId(shellId), shellId)
|
||||||
|
assert.ok(argumentFieldId(shellId).startsWith(ARGUMENT_FIELD_ID_PREFIX))
|
||||||
|
assert.notEqual(argumentFieldChoicesId(shellId), shellId)
|
||||||
|
assert.notEqual(argumentFieldValueId(shellId), shellId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
export function readPrefilledArgumentsFromNavigation() {
|
export function readPrefilledArgumentsFromNavigation (historyState = globalThis.window?.history?.state) {
|
||||||
const state = window.history.state
|
if (historyState?.prefilledArguments && typeof historyState.prefilledArguments === 'object') {
|
||||||
if (state?.prefilledArguments && typeof state.prefilledArguments === 'object') {
|
return { ...historyState.prefilledArguments }
|
||||||
return { ...state.prefilledArguments }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitialArgumentValue(paramName, prefilledArguments = {}) {
|
export function getInitialArgumentValue (paramName, prefilledArguments = {}, search = globalThis.window?.location?.search ?? '') {
|
||||||
const safePrefilledArguments = prefilledArguments && typeof prefilledArguments === 'object'
|
const safePrefilledArguments = prefilledArguments && typeof prefilledArguments === 'object'
|
||||||
? prefilledArguments
|
? prefilledArguments
|
||||||
: {}
|
: {}
|
||||||
|
|
@ -16,6 +15,6 @@ export function getInitialArgumentValue(paramName, prefilledArguments = {}) {
|
||||||
return safePrefilledArguments[paramName]
|
return safePrefilledArguments[paramName]
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(search)
|
||||||
return params.get(paramName)
|
return params.get(paramName)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,36 +4,31 @@ import assert from 'node:assert/strict'
|
||||||
import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from './prefilledArguments.js'
|
import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from './prefilledArguments.js'
|
||||||
|
|
||||||
test('readPrefilledArgumentsFromNavigation returns navigation state values', () => {
|
test('readPrefilledArgumentsFromNavigation returns navigation state values', () => {
|
||||||
const originalState = window.history.state
|
assert.deepEqual(
|
||||||
window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
|
readPrefilledArgumentsFromNavigation({ prefilledArguments: { ansible_host: '10.0.0.1' } }),
|
||||||
|
{ ansible_host: '10.0.0.1' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
assert.deepEqual(readPrefilledArgumentsFromNavigation(), { ansible_host: '10.0.0.1' })
|
test('readPrefilledArgumentsFromNavigation returns empty object when state is absent', () => {
|
||||||
|
assert.deepEqual(readPrefilledArgumentsFromNavigation({}), {})
|
||||||
window.history.replaceState(originalState, '')
|
assert.deepEqual(readPrefilledArgumentsFromNavigation(undefined), {})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getInitialArgumentValue prefers navigation state over query params', () => {
|
test('getInitialArgumentValue prefers navigation state over query params', () => {
|
||||||
const originalState = window.history.state
|
assert.equal(
|
||||||
const originalSearch = window.location.search
|
getInitialArgumentValue(
|
||||||
|
'ansible_host',
|
||||||
window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
|
{ ansible_host: '10.0.0.1' },
|
||||||
window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
|
'?ansible_host=10.0.0.2'
|
||||||
|
),
|
||||||
assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.1')
|
'10.0.0.1'
|
||||||
|
)
|
||||||
window.history.replaceState(originalState, '')
|
|
||||||
window.history.replaceState(window.history.state, '', originalSearch || '/')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getInitialArgumentValue falls back to query params when state is absent', () => {
|
test('getInitialArgumentValue falls back to query params when state is absent', () => {
|
||||||
const originalState = window.history.state
|
assert.equal(
|
||||||
const originalSearch = window.location.search
|
getInitialArgumentValue('ansible_host', {}, '?ansible_host=10.0.0.2'),
|
||||||
|
'10.0.0.2'
|
||||||
window.history.replaceState({}, '')
|
)
|
||||||
window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
|
|
||||||
|
|
||||||
assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.2')
|
|
||||||
|
|
||||||
window.history.replaceState(originalState, '')
|
|
||||||
window.history.replaceState(window.history.state, '', originalSearch || '/')
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,14 @@
|
||||||
<template v-if="actionArguments.length > 0">
|
<template v-if="actionArguments.length > 0">
|
||||||
|
|
||||||
<template v-for="arg in actionArguments" :key="arg.name">
|
<template v-for="arg in actionArguments" :key="arg.name">
|
||||||
<label v-if="arg.type !== 'checklist'" :for="arg.name">
|
<label v-if="arg.type !== 'checklist'" :for="argumentFieldId(arg.name)">
|
||||||
{{ formatLabel(arg.title) }}
|
{{ formatLabel(arg.title) }}
|
||||||
</label>
|
</label>
|
||||||
<div v-else class="argument-label">
|
<div v-else class="argument-label">
|
||||||
{{ formatLabel(arg.title) }}
|
{{ formatLabel(arg.title) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="`${arg.name}-choices`">
|
<datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="argumentFieldChoicesId(arg.name)">
|
||||||
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
|
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
|
||||||
{{ suggestion }}
|
{{ suggestion }}
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -35,18 +35,18 @@
|
||||||
</option>
|
</option>
|
||||||
</datalist>
|
</datalist>
|
||||||
|
|
||||||
<ChoiceCombobox v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name"
|
<ChoiceCombobox v-if="getInputComponent(arg) === 'select'" :id="argumentFieldId(arg.name)" :name="arg.name"
|
||||||
:choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
|
:choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
|
||||||
@update:model-value="handleChoiceUpdate(arg, $event)" />
|
@update:model-value="handleChoiceUpdate(arg, $event)" />
|
||||||
|
|
||||||
<ChoiceChecklist v-else-if="arg.type === 'checklist'" :id="arg.name" :name="arg.name"
|
<ChoiceChecklist v-else-if="arg.type === 'checklist'" :name="arg.name"
|
||||||
:label="arg.title" :choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
|
:label="arg.title" :choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
|
||||||
@update:model-value="handleChoiceUpdate(arg, $event)" />
|
@update:model-value="handleChoiceUpdate(arg, $event)" />
|
||||||
|
|
||||||
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
|
<component v-else :is="getInputComponent(arg)" :id="argumentFieldId(arg.name)" :name="arg.name"
|
||||||
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
|
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
|
||||||
:checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
|
:checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
|
||||||
:list="(arg.suggestions || getBrowserSuggestions(arg).length > 0) ? `${arg.name}-choices` : undefined"
|
:list="(arg.suggestions || getBrowserSuggestions(arg).length > 0) ? argumentFieldChoicesId(arg.name) : undefined"
|
||||||
:type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
|
:type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
|
||||||
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
|
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
|
||||||
:step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)"
|
:step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)"
|
||||||
|
|
@ -90,6 +90,11 @@ import {
|
||||||
actionRequiresJustification,
|
actionRequiresJustification,
|
||||||
applyArgumentTemplate
|
applyArgumentTemplate
|
||||||
} from '../utils/justificationTemplate.js'
|
} from '../utils/justificationTemplate.js'
|
||||||
|
import {
|
||||||
|
argumentFieldChoicesId,
|
||||||
|
argumentFieldId,
|
||||||
|
argumentFieldValidationElementId
|
||||||
|
} from '../utils/argumentFieldIds.js'
|
||||||
import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
|
import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -274,11 +279,7 @@ function handleChange(arg, event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getValidationElement(arg) {
|
function getValidationElement(arg) {
|
||||||
if (arg.type === 'checklist') {
|
return document.getElementById(argumentFieldValidationElementId(arg))
|
||||||
return document.getElementById(`${arg.name}-value`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return document.getElementById(arg.name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChoiceUpdate(arg, value) {
|
function handleChoiceUpdate(arg, value) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,15 @@ import { Condition } from 'selenium-webdriver'
|
||||||
|
|
||||||
export const DEFAULT_UI_WAIT_MS = 3000
|
export const DEFAULT_UI_WAIT_MS = 3000
|
||||||
|
|
||||||
|
// Keep Selenium helpers in lockstep with the frontend DOM id helpers.
|
||||||
|
export {
|
||||||
|
ARGUMENT_FIELD_ID_PREFIX,
|
||||||
|
argumentFieldChoicesId,
|
||||||
|
argumentFieldId,
|
||||||
|
argumentFieldValidationElementId,
|
||||||
|
argumentFieldValueId
|
||||||
|
} from '../../frontend/resources/vue/utils/argumentFieldIds.js'
|
||||||
|
|
||||||
const executionDialogStatusBy = By.css('.execution-dialog-status')
|
const executionDialogStatusBy = By.css('.execution-dialog-status')
|
||||||
|
|
||||||
export async function getActionButtons () {
|
export async function getActionButtons () {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { describe, it, before, after } from 'mocha'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import { By, Condition } from 'selenium-webdriver'
|
||||||
|
import {
|
||||||
|
DEFAULT_UI_WAIT_MS,
|
||||||
|
argumentFieldId,
|
||||||
|
getRootAndWait,
|
||||||
|
getActionButton,
|
||||||
|
takeScreenshotOnFailure,
|
||||||
|
waitForArgumentFormPage,
|
||||||
|
waitForArgumentFormReady,
|
||||||
|
waitForLogsPage,
|
||||||
|
waitForExecutionComplete,
|
||||||
|
getTerminalBuffer,
|
||||||
|
} from '../../lib/elements.js'
|
||||||
|
|
||||||
|
async function openArgumentForm (actionTitle) {
|
||||||
|
await getRootAndWait()
|
||||||
|
const btn = await getActionButton(webdriver, actionTitle)
|
||||||
|
await btn.click()
|
||||||
|
await waitForArgumentFormPage()
|
||||||
|
await waitForArgumentFormReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForStartButtonEnabled () {
|
||||||
|
await webdriver.wait(
|
||||||
|
new Condition('wait for Start button to be enabled', async () => {
|
||||||
|
const submitButton = await webdriver.findElement(By.css('button[name="start"]'))
|
||||||
|
return await submitButton.isEnabled()
|
||||||
|
}),
|
||||||
|
DEFAULT_UI_WAIT_MS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTerminalOutput (expectedSubstring) {
|
||||||
|
await webdriver.wait(
|
||||||
|
new Condition(`wait for terminal output containing ${expectedSubstring}`, async () => {
|
||||||
|
try {
|
||||||
|
const terminalReady = await webdriver.executeScript(`
|
||||||
|
return !!(window.terminal && window.terminal.getBufferAsString);
|
||||||
|
`)
|
||||||
|
if (!terminalReady) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await getTerminalBuffer()
|
||||||
|
return output && output.includes(expectedSubstring)
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DEFAULT_UI_WAIT_MS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('config: argumentIdCollision', function () {
|
||||||
|
this.timeout(10000)
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
await runner.start('argumentIdCollision')
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await runner.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
takeScreenshotOnFailure(this.currentTest, webdriver)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Argument named content validates and submits (#1071)', async function () {
|
||||||
|
await openArgumentForm('Test content argument collision')
|
||||||
|
|
||||||
|
const contentWrapper = await webdriver.findElement(By.id('content'))
|
||||||
|
expect(await contentWrapper.getTagName()).to.equal('div')
|
||||||
|
|
||||||
|
const textarea = await webdriver.findElement(By.id(argumentFieldId('content')))
|
||||||
|
expect(await textarea.getTagName()).to.equal('textarea')
|
||||||
|
expect(await textarea.getAttribute('id')).to.not.equal('content')
|
||||||
|
|
||||||
|
const label = await webdriver.findElement(By.css(`label[for="${argumentFieldId('content')}"]`))
|
||||||
|
expect(await label.getText()).to.contain('Cmd input')
|
||||||
|
|
||||||
|
await textarea.sendKeys('hello from collision test')
|
||||||
|
|
||||||
|
const submitButton = await webdriver.findElement(By.css('button[name="start"]'))
|
||||||
|
await waitForStartButtonEnabled()
|
||||||
|
await submitButton.click()
|
||||||
|
await waitForLogsPage()
|
||||||
|
await waitForExecutionComplete()
|
||||||
|
await waitForTerminalOutput('Cmd input: hello from collision test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Argument named layout validates and submits (#1071)', async function () {
|
||||||
|
await openArgumentForm('Test layout argument collision')
|
||||||
|
|
||||||
|
const layoutWrapper = await webdriver.findElement(By.id('layout'))
|
||||||
|
expect(await layoutWrapper.getTagName()).to.equal('div')
|
||||||
|
|
||||||
|
const input = await webdriver.findElement(By.id(argumentFieldId('layout')))
|
||||||
|
expect(await input.getAttribute('type')).to.equal('text')
|
||||||
|
expect(await input.getAttribute('id')).to.not.equal('layout')
|
||||||
|
|
||||||
|
await input.sendKeys('testlayoutvalue')
|
||||||
|
|
||||||
|
const submitButton = await webdriver.findElement(By.css('button[name="start"]'))
|
||||||
|
await waitForStartButtonEnabled()
|
||||||
|
await submitButton.click()
|
||||||
|
await waitForLogsPage()
|
||||||
|
await waitForExecutionComplete()
|
||||||
|
await waitForTerminalOutput('Layout value: testlayoutvalue')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Namespaced argument ids do not match app-shell ids', async function () {
|
||||||
|
await openArgumentForm('Test content argument collision')
|
||||||
|
|
||||||
|
const namespacedIds = await webdriver.executeScript(`
|
||||||
|
const requiredShellIds = ['content', 'layout'];
|
||||||
|
const optionalShellIds = ['banner', 'app', 'mainnav', 'big-error'];
|
||||||
|
const fieldId = arguments[0];
|
||||||
|
const fieldElement = document.getElementById(fieldId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fieldTag: fieldElement?.tagName?.toLowerCase() ?? null,
|
||||||
|
required: requiredShellIds.map((shellId) => {
|
||||||
|
const shellElement = document.getElementById(shellId);
|
||||||
|
return {
|
||||||
|
shellId,
|
||||||
|
shellTag: shellElement?.tagName?.toLowerCase() ?? null,
|
||||||
|
sameElement: shellElement != null && shellElement === fieldElement
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
optional: optionalShellIds.map((shellId) => {
|
||||||
|
const shellElement = document.getElementById(shellId);
|
||||||
|
return {
|
||||||
|
shellId,
|
||||||
|
sameElement: shellElement != null && shellElement === fieldElement
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
`, argumentFieldId('content'))
|
||||||
|
|
||||||
|
expect(namespacedIds.fieldTag, `argument field ${argumentFieldId('content')} should exist`).to.equal('textarea')
|
||||||
|
|
||||||
|
for (const entry of namespacedIds.required) {
|
||||||
|
expect(entry.shellTag, `app-shell #${entry.shellId} should exist`).to.not.equal(null)
|
||||||
|
expect(entry.sameElement, `arg field must not resolve to #${entry.shellId}`).to.be.false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of namespacedIds.optional) {
|
||||||
|
expect(entry.sameElement, `arg field must not resolve to #${entry.shellId}`).to.be.false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||||
|
|
||||||
|
logLevel: "DEBUG"
|
||||||
|
checkForUpdates: false
|
||||||
|
defaultPopupOnStart: execution-dialog
|
||||||
|
|
||||||
|
actions:
|
||||||
|
- title: Test content argument collision
|
||||||
|
exec:
|
||||||
|
- echo
|
||||||
|
- "Cmd input: {{ content }}"
|
||||||
|
icon: ping
|
||||||
|
arguments:
|
||||||
|
- name: content
|
||||||
|
title: Cmd input
|
||||||
|
type: raw_string_multiline
|
||||||
|
description: Argument name collides with the app-shell #content wrapper
|
||||||
|
|
||||||
|
- title: Test layout argument collision
|
||||||
|
exec:
|
||||||
|
- echo
|
||||||
|
- "Layout value: {{ layout }}"
|
||||||
|
icon: ping
|
||||||
|
arguments:
|
||||||
|
- name: layout
|
||||||
|
title: Layout text
|
||||||
|
type: ascii
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
waitForArgumentFormPage,
|
waitForArgumentFormPage,
|
||||||
waitForLogsPage,
|
waitForLogsPage,
|
||||||
waitForExecutionComplete,
|
waitForExecutionComplete,
|
||||||
|
argumentFieldId,
|
||||||
} from '../../lib/elements.js'
|
} from '../../lib/elements.js'
|
||||||
|
|
||||||
async function openCheckboxArgumentForm() {
|
async function openCheckboxArgumentForm() {
|
||||||
|
|
@ -21,7 +22,7 @@ async function openCheckboxArgumentForm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCheckboxInput() {
|
async function getCheckboxInput() {
|
||||||
return await webdriver.findElement(By.id('confirm'))
|
return await webdriver.findElement(By.id(argumentFieldId('confirm')))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCheckboxForm() {
|
async function submitCheckboxForm() {
|
||||||
|
|
@ -75,7 +76,7 @@ describe('config: checkbox', function () {
|
||||||
expect(await checkboxInput.getTagName()).to.equal('input')
|
expect(await checkboxInput.getTagName()).to.equal('input')
|
||||||
expect(await checkboxInput.getAttribute('type')).to.equal('checkbox')
|
expect(await checkboxInput.getAttribute('type')).to.equal('checkbox')
|
||||||
|
|
||||||
const label = await webdriver.findElement(By.css('label[for="confirm"]'))
|
const label = await webdriver.findElement(By.css(`label[for="${argumentFieldId('confirm')}"]`))
|
||||||
expect(await label.getText()).to.contain('Confirm option')
|
expect(await label.getText()).to.contain('Confirm option')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
takeScreenshotOnFailure,
|
takeScreenshotOnFailure,
|
||||||
waitForArgumentFormReady,
|
waitForArgumentFormReady,
|
||||||
waitForLogsPage,
|
waitForLogsPage,
|
||||||
|
argumentFieldId,
|
||||||
} from '../../lib/elements.js'
|
} from '../../lib/elements.js'
|
||||||
|
|
||||||
describe('config: datetime', function () {
|
describe('config: datetime', function () {
|
||||||
|
|
@ -32,7 +33,7 @@ describe('config: datetime', function () {
|
||||||
await waitForArgumentFormReady()
|
await waitForArgumentFormReady()
|
||||||
|
|
||||||
// Find the datetime input field
|
// Find the datetime input field
|
||||||
const datetimeInput = await webdriver.findElement(By.id('datetime'))
|
const datetimeInput = await webdriver.findElement(By.id(argumentFieldId('datetime')))
|
||||||
|
|
||||||
// Verify it's a datetime-local input type
|
// Verify it's a datetime-local input type
|
||||||
const inputType = await datetimeInput.getAttribute('type')
|
const inputType = await datetimeInput.getAttribute('type')
|
||||||
|
|
@ -43,7 +44,7 @@ describe('config: datetime', function () {
|
||||||
expect(step).to.equal('1', 'Step attribute should be 1')
|
expect(step).to.equal('1', 'Step attribute should be 1')
|
||||||
|
|
||||||
// Verify the label is present
|
// Verify the label is present
|
||||||
const label = await webdriver.findElement(By.css('label[for="datetime"]'))
|
const label = await webdriver.findElement(By.css(`label[for="${argumentFieldId('datetime')}"]`))
|
||||||
expect(await label.getText()).to.contain('Select a date and time')
|
expect(await label.getText()).to.contain('Select a date and time')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -57,7 +58,7 @@ describe('config: datetime', function () {
|
||||||
await waitForArgumentFormReady()
|
await waitForArgumentFormReady()
|
||||||
|
|
||||||
// Find the datetime input field
|
// Find the datetime input field
|
||||||
const datetimeInput = await webdriver.findElement(By.id('datetime'))
|
const datetimeInput = await webdriver.findElement(By.id(argumentFieldId('datetime')))
|
||||||
|
|
||||||
// Set a datetime value (format: YYYY-MM-DDTHH:mm)
|
// Set a datetime value (format: YYYY-MM-DDTHH:mm)
|
||||||
// datetime-local returns values without seconds, backend will add :00
|
// datetime-local returns values without seconds, backend will add :00
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { expect } from 'chai'
|
||||||
import { By, Condition } from 'selenium-webdriver'
|
import { By, Condition } from 'selenium-webdriver'
|
||||||
import {
|
import {
|
||||||
DEFAULT_UI_WAIT_MS,
|
DEFAULT_UI_WAIT_MS,
|
||||||
|
argumentFieldChoicesId,
|
||||||
|
argumentFieldId,
|
||||||
getRootAndWait,
|
getRootAndWait,
|
||||||
getActionButton,
|
getActionButton,
|
||||||
takeScreenshotOnFailure,
|
takeScreenshotOnFailure,
|
||||||
|
|
@ -73,15 +75,15 @@ async function openArgumentForm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTestInput() {
|
async function getTestInput() {
|
||||||
return await webdriver.findElement(By.id('testInput'))
|
return await webdriver.findElement(By.id(argumentFieldId('testInput')))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTestInput2() {
|
async function getTestInput2() {
|
||||||
return await webdriver.findElement(By.id('testInput2'))
|
return await webdriver.findElement(By.id(argumentFieldId('testInput2')))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDatalistOptions(inputName = 'testInput') {
|
async function getDatalistOptions(inputName = 'testInput') {
|
||||||
return await webdriver.findElements(By.css(`datalist#${inputName}-choices option`))
|
return await webdriver.findElements(By.css(`datalist#${argumentFieldChoicesId(inputName)} option`))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitForm() {
|
async function submitForm() {
|
||||||
|
|
@ -118,14 +120,14 @@ describe('config: suggestionsBrowserKey', function () {
|
||||||
expect(await input1.getTagName()).to.equal('input')
|
expect(await input1.getTagName()).to.equal('input')
|
||||||
expect(await input1.getAttribute('type')).to.equal('text')
|
expect(await input1.getAttribute('type')).to.equal('text')
|
||||||
|
|
||||||
const label1 = await webdriver.findElement(By.css('label[for="testInput"]'))
|
const label1 = await webdriver.findElement(By.css(`label[for="${argumentFieldId('testInput')}"]`))
|
||||||
expect(await label1.getText()).to.contain('Test Input')
|
expect(await label1.getText()).to.contain('Test Input')
|
||||||
|
|
||||||
const input2 = await getTestInput2()
|
const input2 = await getTestInput2()
|
||||||
expect(await input2.getTagName()).to.equal('input')
|
expect(await input2.getTagName()).to.equal('input')
|
||||||
expect(await input2.getAttribute('type')).to.equal('text')
|
expect(await input2.getAttribute('type')).to.equal('text')
|
||||||
|
|
||||||
const label2 = await webdriver.findElement(By.css('label[for="testInput2"]'))
|
const label2 = await webdriver.findElement(By.css(`label[for="${argumentFieldId('testInput2')}"]`))
|
||||||
expect(await label2.getText()).to.contain('Test Input 2')
|
expect(await label2.getText()).to.contain('Test Input 2')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -161,7 +163,7 @@ describe('config: suggestionsBrowserKey', function () {
|
||||||
|
|
||||||
await openArgumentForm()
|
await openArgumentForm()
|
||||||
|
|
||||||
const datalist = await webdriver.findElement(By.id('testInput-choices'))
|
const datalist = await webdriver.findElement(By.id(argumentFieldChoicesId('testInput')))
|
||||||
expect(datalist).to.not.be.null
|
expect(datalist).to.not.be.null
|
||||||
|
|
||||||
const options = await getDatalistOptions()
|
const options = await getDatalistOptions()
|
||||||
|
|
@ -241,7 +243,7 @@ describe('config: suggestionsBrowserKey', function () {
|
||||||
|
|
||||||
await openArgumentForm()
|
await openArgumentForm()
|
||||||
|
|
||||||
const datalist1 = await webdriver.findElement(By.id('testInput-choices'))
|
const datalist1 = await webdriver.findElement(By.id(argumentFieldChoicesId('testInput')))
|
||||||
expect(datalist1).to.not.be.null
|
expect(datalist1).to.not.be.null
|
||||||
const options1 = await getDatalistOptions('testInput')
|
const options1 = await getDatalistOptions('testInput')
|
||||||
let foundInInput1 = false
|
let foundInInput1 = false
|
||||||
|
|
@ -254,7 +256,7 @@ describe('config: suggestionsBrowserKey', function () {
|
||||||
}
|
}
|
||||||
expect(foundInInput1).to.be.true
|
expect(foundInInput1).to.be.true
|
||||||
|
|
||||||
const datalist2 = await webdriver.findElement(By.id('testInput2-choices'))
|
const datalist2 = await webdriver.findElement(By.id(argumentFieldChoicesId('testInput2')))
|
||||||
expect(datalist2).to.not.be.null
|
expect(datalist2).to.not.be.null
|
||||||
const options2 = await getDatalistOptions('testInput2')
|
const options2 = await getDatalistOptions('testInput2')
|
||||||
let foundInInput2 = false
|
let foundInInput2 = false
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue