Explorar o código

fix: prefix form arguments to prevent collisions with other page elements

jamesread hai 1 mes
pai
achega
62ae4fea2b

+ 1 - 1
frontend/package.json

@@ -10,7 +10,7 @@
 		"stylelint-config-standard": "^40.0.0"
 	},
 	"scripts": {
-		"test": "node --test resources/vue/components/*.test.mjs"
+		"test": "node --test resources/vue/components/*.test.mjs resources/vue/utils/*.test.mjs"
 	},
 	"author": "",
 	"parcelIgnore": [

+ 15 - 8
frontend/resources/vue/components/ChoiceChecklist.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="choice-checklist" :id="`${id}-wrapper`">
+  <div class="choice-checklist" :id="wrapperId">
     <div class="choice-checklist-controls">
       <button type="button" class="choice-checklist-control" @click="selectAll">
         Select all
@@ -14,10 +14,10 @@
         v-for="(choice, index) in choices"
         :key="choice.value"
         class="choice-checklist-item"
-        :for="`${id}-${index}`"
+        :for="optionId(index)"
       >
         <input
-          :id="`${id}-${index}`"
+          :id="optionId(index)"
           type="checkbox"
           :checked="isSelected(choice.value)"
           @change="handleToggle(choice.value)"
@@ -26,7 +26,7 @@
       </label>
     </fieldset>
     <input
-      :id="`${id}-value`"
+      :id="valueId"
       :name="name"
       type="text"
       class="visually-hidden choice-checklist-value"
@@ -47,12 +47,13 @@ import {
   parseChecklistValue,
   toggleChoice
 } from '../utils/choiceChecklistHelpers.js'
+import {
+  argumentFieldOptionId,
+  argumentFieldValueId,
+  argumentFieldWrapperId
+} from '../utils/argumentFieldIds.js'
 
 const props = defineProps({
-  id: {
-    type: String,
-    required: true
-  },
   name: {
     type: String,
     required: true
@@ -78,6 +79,12 @@ const props = defineProps({
 const emit = defineEmits(['update: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) {
   return selectedValues.value.includes(value)

+ 11 - 3
frontend/resources/vue/components/ChoiceCombobox.vue

@@ -31,7 +31,7 @@
     >
       <li
         v-for="(choice, index) in filteredChoices"
-        :id="`${listboxId}-option-${index}`"
+        :id="listboxOptionId(index)"
         :key="choice.value"
         role="option"
         :aria-selected="choice.value === modelValue"
@@ -56,6 +56,10 @@ import {
   choiceDisplayLabel,
   syncStateFromModelValue
 } from './choiceComboboxHelpers.js'
+import {
+  argumentFieldListboxId,
+  argumentFieldListboxOptionId
+} from '../utils/argumentFieldIds.js'
 
 const props = defineProps({
   id: {
@@ -91,16 +95,20 @@ const query = ref('')
 const isUserFiltering = ref(false)
 const highlightedIndex = ref(0)
 
-const listboxId = computed(() => `${props.id}-listbox`)
+const listboxId = computed(() => argumentFieldListboxId(props.name))
 
 const activeDescendantId = computed(() => {
   if (!isOpen.value || filteredChoices.value.length === 0) {
     return undefined
   }
 
-  return `${listboxId.value}-option-${highlightedIndex.value}`
+  return listboxOptionId(highlightedIndex.value)
 })
 
+function listboxOptionId (index) {
+  return argumentFieldListboxOptionId(props.name, index)
+}
+
 const placeholderText = computed(() => {
   if (props.required) {
     return 'Search and select...'

+ 41 - 0
frontend/resources/vue/utils/argumentFieldIds.js

@@ -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)
+}

+ 85 - 0
frontend/resources/vue/utils/argumentFieldIds.test.mjs

@@ -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)
+  }
+})

+ 5 - 6
frontend/resources/vue/utils/prefilledArguments.js

@@ -1,13 +1,12 @@
-export function readPrefilledArgumentsFromNavigation() {
-	const state = window.history.state
-	if (state?.prefilledArguments && typeof state.prefilledArguments === 'object') {
-		return { ...state.prefilledArguments }
+export function readPrefilledArgumentsFromNavigation (historyState = globalThis.window?.history?.state) {
+	if (historyState?.prefilledArguments && typeof historyState.prefilledArguments === 'object') {
+		return { ...historyState.prefilledArguments }
 	}
 
 	return {}
 }
 
-export function getInitialArgumentValue(paramName, prefilledArguments = {}) {
+export function getInitialArgumentValue (paramName, prefilledArguments = {}, search = globalThis.window?.location?.search ?? '') {
 	const safePrefilledArguments = prefilledArguments && typeof prefilledArguments === 'object'
 		? prefilledArguments
 		: {}
@@ -16,6 +15,6 @@ export function getInitialArgumentValue(paramName, prefilledArguments = {}) {
 		return safePrefilledArguments[paramName]
 	}
 
-	const params = new URLSearchParams(window.location.search)
+	const params = new URLSearchParams(search)
 	return params.get(paramName)
 }

+ 20 - 25
frontend/resources/vue/utils/prefilledArguments.test.mjs

@@ -4,36 +4,31 @@ import assert from 'node:assert/strict'
 import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from './prefilledArguments.js'
 
 test('readPrefilledArgumentsFromNavigation returns navigation state values', () => {
-	const originalState = window.history.state
-	window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
-
-	assert.deepEqual(readPrefilledArgumentsFromNavigation(), { ansible_host: '10.0.0.1' })
+	assert.deepEqual(
+		readPrefilledArgumentsFromNavigation({ prefilledArguments: { ansible_host: '10.0.0.1' } }),
+		{ ansible_host: '10.0.0.1' }
+	)
+})
 
-	window.history.replaceState(originalState, '')
+test('readPrefilledArgumentsFromNavigation returns empty object when state is absent', () => {
+	assert.deepEqual(readPrefilledArgumentsFromNavigation({}), {})
+	assert.deepEqual(readPrefilledArgumentsFromNavigation(undefined), {})
 })
 
 test('getInitialArgumentValue prefers navigation state over query params', () => {
-	const originalState = window.history.state
-	const originalSearch = window.location.search
-
-	window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
-	window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
-
-	assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.1')
-
-	window.history.replaceState(originalState, '')
-	window.history.replaceState(window.history.state, '', originalSearch || '/')
+	assert.equal(
+		getInitialArgumentValue(
+			'ansible_host',
+			{ ansible_host: '10.0.0.1' },
+			'?ansible_host=10.0.0.2'
+		),
+		'10.0.0.1'
+	)
 })
 
 test('getInitialArgumentValue falls back to query params when state is absent', () => {
-	const originalState = window.history.state
-	const originalSearch = window.location.search
-
-	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 || '/')
+	assert.equal(
+		getInitialArgumentValue('ansible_host', {}, '?ansible_host=10.0.0.2'),
+		'10.0.0.2'
+	)
 })

+ 12 - 11
frontend/resources/vue/views/ArgumentForm.vue

@@ -19,14 +19,14 @@
         <template v-if="actionArguments.length > 0">
 
           <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) }}
               </label>
               <div v-else class="argument-label">
                 {{ formatLabel(arg.title) }}
               </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">
                   {{ suggestion }}
                 </option>
@@ -35,18 +35,18 @@
                 </option>
               </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"
                 @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"
                 @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)"
                 :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"
                 :rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
                 :step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)"
@@ -90,6 +90,11 @@ import {
   actionRequiresJustification,
   applyArgumentTemplate
 } from '../utils/justificationTemplate.js'
+import {
+  argumentFieldChoicesId,
+  argumentFieldId,
+  argumentFieldValidationElementId
+} from '../utils/argumentFieldIds.js'
 import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
 
 const router = useRouter()
@@ -274,11 +279,7 @@ function handleChange(arg, event) {
 }
 
 function getValidationElement(arg) {
-  if (arg.type === 'checklist') {
-    return document.getElementById(`${arg.name}-value`)
-  }
-
-  return document.getElementById(arg.name)
+  return document.getElementById(argumentFieldValidationElementId(arg))
 }
 
 function handleChoiceUpdate(arg, value) {

+ 9 - 0
integration-tests/lib/elements.js

@@ -5,6 +5,15 @@ import { Condition } from 'selenium-webdriver'
 
 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')
 
 export async function getActionButtons () {

+ 154 - 0
integration-tests/tests/argumentIdCollision/argumentIdCollision.mjs

@@ -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
+    }
+  })
+})

+ 28 - 0
integration-tests/tests/argumentIdCollision/config.yaml

@@ -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

+ 3 - 2
integration-tests/tests/checkbox/checkbox.mjs

@@ -10,6 +10,7 @@ import {
   waitForArgumentFormPage,
   waitForLogsPage,
   waitForExecutionComplete,
+  argumentFieldId,
 } from '../../lib/elements.js'
 
 async function openCheckboxArgumentForm() {
@@ -21,7 +22,7 @@ async function openCheckboxArgumentForm() {
 }
 
 async function getCheckboxInput() {
-  return await webdriver.findElement(By.id('confirm'))
+  return await webdriver.findElement(By.id(argumentFieldId('confirm')))
 }
 
 async function submitCheckboxForm() {
@@ -75,7 +76,7 @@ describe('config: checkbox', function () {
     expect(await checkboxInput.getTagName()).to.equal('input')
     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')
   })
 

+ 4 - 3
integration-tests/tests/datetime/datetime.mjs

@@ -7,6 +7,7 @@ import {
   takeScreenshotOnFailure,
   waitForArgumentFormReady,
   waitForLogsPage,
+  argumentFieldId,
 } from '../../lib/elements.js'
 
 describe('config: datetime', function () {
@@ -32,7 +33,7 @@ describe('config: datetime', function () {
     await waitForArgumentFormReady()
 
     // 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
     const inputType = await datetimeInput.getAttribute('type')
@@ -43,7 +44,7 @@ describe('config: datetime', function () {
     expect(step).to.equal('1', 'Step attribute should be 1')
 
     // 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')
   })
 
@@ -57,7 +58,7 @@ describe('config: datetime', function () {
     await waitForArgumentFormReady()
 
     // 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)
     // datetime-local returns values without seconds, backend will add :00

+ 10 - 8
integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs

@@ -3,6 +3,8 @@ import { expect } from 'chai'
 import { By, Condition } from 'selenium-webdriver'
 import {
   DEFAULT_UI_WAIT_MS,
+  argumentFieldChoicesId,
+  argumentFieldId,
   getRootAndWait,
   getActionButton,
   takeScreenshotOnFailure,
@@ -73,15 +75,15 @@ async function openArgumentForm() {
 }
 
 async function getTestInput() {
-  return await webdriver.findElement(By.id('testInput'))
+  return await webdriver.findElement(By.id(argumentFieldId('testInput')))
 }
 
 async function getTestInput2() {
-  return await webdriver.findElement(By.id('testInput2'))
+  return await webdriver.findElement(By.id(argumentFieldId('testInput2')))
 }
 
 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() {
@@ -118,14 +120,14 @@ describe('config: suggestionsBrowserKey', function () {
     expect(await input1.getTagName()).to.equal('input')
     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')
 
     const input2 = await getTestInput2()
     expect(await input2.getTagName()).to.equal('input')
     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')
   })
 
@@ -161,7 +163,7 @@ describe('config: suggestionsBrowserKey', function () {
 
     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
 
     const options = await getDatalistOptions()
@@ -241,7 +243,7 @@ describe('config: suggestionsBrowserKey', function () {
 
     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
     const options1 = await getDatalistOptions('testInput')
     let foundInInput1 = false
@@ -254,7 +256,7 @@ describe('config: suggestionsBrowserKey', function () {
     }
     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
     const options2 = await getDatalistOptions('testInput2')
     let foundInInput2 = false