Просмотр исходного кода

Merge branch 'next' into build/make-generation-reproducible

Resolve Makefile/.PHONY conflicts with docs-check and unittests-race,
and address generated-check review feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>
jamesread 1 день назад
Родитель
Сommit
304e2c82eb
41 измененных файлов с 328 добавлено и 97 удалено
  1. 18 0
      .github/workflows/codestyle.yml
  2. 2 0
      .github/workflows/docs-antora.yml
  3. 4 0
      .github/workflows/generated.yml
  4. 36 0
      .github/workflows/race.yml
  5. 7 0
      .pre-commit-config.yaml
  6. 2 0
      AGENTS.md
  7. 1 0
      CONTRIBUTING.adoc
  8. 7 1
      Makefile
  9. 7 6
      frontend/Makefile
  10. 2 0
      frontend/package.json
  11. 12 12
      frontend/vite.config.mjs
  12. 0 8
      integration-tests/.eslintrc.yml
  13. 9 3
      integration-tests/Makefile
  14. 31 0
      integration-tests/eslint.config.mjs
  15. 1 1
      integration-tests/lib/elements.js
  16. 36 0
      integration-tests/package-lock.json
  17. 5 1
      integration-tests/package.json
  18. 1 1
      integration-tests/scripts/find-flakey-tests-inf.mjs
  19. 1 1
      integration-tests/tests/argumentActionFlash/argumentActionFlash.mjs
  20. 1 1
      integration-tests/tests/argumentIdCollision/argumentIdCollision.mjs
  21. 1 2
      integration-tests/tests/authRequireGuestsToLogin/authRequireGuestsToLogin.mjs
  22. 1 1
      integration-tests/tests/checkbox/checkbox.mjs
  23. 1 1
      integration-tests/tests/checklist/checklist.mjs
  24. 1 1
      integration-tests/tests/confirmation/confirmation.mjs
  25. 1 1
      integration-tests/tests/dashboardsWithBasicFieldsets/dashboardsWithBasicFieldsets.mjs
  26. 0 1
      integration-tests/tests/emptyDashboardsAreHidden/emptyDashboardsAreHidden.mjs
  27. 1 3
      integration-tests/tests/hiddenFooter/hiddenFooter.mjs
  28. 2 5
      integration-tests/tests/hiddenNav/hiddenNav.mjs
  29. 1 1
      integration-tests/tests/justification/justification.mjs
  30. 5 6
      integration-tests/tests/localAuth/localAuth.mjs
  31. 1 2
      integration-tests/tests/logPersistence/logPersistence.mjs
  32. 1 1
      integration-tests/tests/multipleDropdowns/multipleDropdowns.mjs
  33. 8 15
      integration-tests/tests/oauthLoginGithub/githubOAuth.mjs
  34. 1 2
      integration-tests/tests/onlyDashboards/onlyDashboards.mjs
  35. 1 3
      integration-tests/tests/sleep/sleep.mjs
  36. 4 5
      integration-tests/tests/stdoutMostRecentExecution/stdoutMostRecentExecution.mjs
  37. 4 1
      service/Makefile
  38. 35 9
      service/internal/executor/executor.go
  39. 33 0
      service/internal/executor/executor_test.go
  40. 6 2
      service/internal/filehelper/file_change_notify.go
  41. 37 0
      service/internal/filehelper/file_change_notify_test.go

+ 18 - 0
.github/workflows/codestyle.yml

@@ -5,6 +5,17 @@ on:
   push:
     paths:
       - '.github/workflows/codestyle.yml'
+      - 'Makefile'
+      - 'frontend/**'
+      - 'integration-tests/**'
+      - 'proto/**'
+      - 'service/**'
+  pull_request:
+    branches:
+      - next
+    paths:
+      - '.github/workflows/codestyle.yml'
+      - 'Makefile'
       - 'frontend/**'
       - 'integration-tests/**'
       - 'proto/**'
@@ -35,9 +46,16 @@ jobs:
         uses: actions/setup-node@v4
         with:
           node-version: '22'
+          cache: 'npm'
+          cache-dependency-path: |
+            frontend/package-lock.json
+            integration-tests/package-lock.json
 
       - name: frontend
         run: make -wC frontend codestyle
 
       - name: frontend unit tests
         run: make -wC frontend unittests
+
+      - name: integration tests codestyle
+        run: make -wC integration-tests codestyle

+ 2 - 0
.github/workflows/docs-antora.yml

@@ -3,12 +3,14 @@ on:
   push:
     paths:
       - 'docs/**'
+      - 'service/internal/config/config.go'
       - 'local-antora-playbook.yml'
       - 'local-antora-playbook-ci.yml'
       - '.github/workflows/docs-antora.yml'
   pull_request:
     paths:
       - 'docs/**'
+      - 'service/internal/config/config.go'
       - 'local-antora-playbook.yml'
       - 'local-antora-playbook-ci.yml'
       - '.github/workflows/docs-antora.yml'

+ 4 - 0
.github/workflows/generated.yml

@@ -12,6 +12,7 @@ on:
       - 'lang/**'
       - 'proto/**'
       - 'service/gen/**'
+      - 'service/generate.go'
       - 'service/go.mod'
       - 'service/go.sum'
       - 'service/Makefile'
@@ -26,6 +27,7 @@ on:
       - 'lang/**'
       - 'proto/**'
       - 'service/gen/**'
+      - 'service/generate.go'
       - 'service/go.mod'
       - 'service/go.sum'
       - 'service/Makefile'
@@ -40,6 +42,8 @@ jobs:
     steps:
       - name: Checkout
         uses: actions/checkout@v4
+        with:
+          persist-credentials: false
 
       - name: Setup Go
         uses: actions/setup-go@v5

+ 36 - 0
.github/workflows/race.yml

@@ -0,0 +1,36 @@
+name: Go race detector
+
+on:
+  push:
+    branches:
+      - main
+      - next
+    paths:
+      - '.github/workflows/race.yml'
+      - 'service/**'
+  pull_request:
+    branches:
+      - next
+    paths:
+      - '.github/workflows/race.yml'
+      - 'service/**'
+
+permissions:
+  contents: read
+
+jobs:
+  race:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup Go
+        uses: actions/setup-go@v5
+        with:
+          go-version-file: 'service/go.mod'
+          cache: true
+          cache-dependency-path: 'service/go.mod'
+
+      - name: Run race detector
+        run: make -wC service unittests-race

+ 7 - 0
.pre-commit-config.yaml

@@ -33,6 +33,13 @@ repos:
 
   - repo: local
     hooks:
+      - id: docs-check
+        name: docs-check
+        entry: make docs-check
+        language: system
+        pass_filenames: false
+        files: ^(docs/|service/internal/config/config\.go|Makefile)
+
       - id: service-codestyle
         name: service-codestyle
         entry: make service-codestyle

+ 2 - 0
AGENTS.md

@@ -22,6 +22,8 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
   - From repo root: `cd service && make unittests`
 - Code style (after editing code in `service/`):
   - From repo root: `cd service && make codestyle` (runs `go fmt` and `golangci-lint`; install linter via `make go-tools`)
+- Documentation checks:
+  - From repo root: `make docs-check` (validates documented config key casing and local AsciiDoc anchor links)
 - Integration tests (Mocha + Selenium):
   - All tests: `make it` (from repo root; builds webui + service binary, then runs Mocha)
   - Single test: `cd integration-tests && npx --yes mocha tests/general/general.mjs`

+ 1 - 0
CONTRIBUTING.adoc

@@ -63,6 +63,7 @@ The project layout is reasonably straightforward;
 
 * See the `Makefile` for common targets. This project was originally created on top of Fedora, but it should be usable on Debian/your faveourite distro with minor changes (if any).
 * End-user documentation (AsciiDoc for link:https://docs.olivetin.app[docs.olivetin.app]) lives in `docs/` as an Antora component; the published site is built from the separate link:https://github.com/OliveTin/docs.olivetin.app[docs.olivetin.app] repository.
+* Run `make docs-check` after changing documentation or the service configuration schema.
 * The API is defined in protobuf+Connect RPC - you will need to `make proto`.
 * The Go daemon is built from the `cmd` and `internal` directories mostly.
 * The webui is just a single page application with a bit of Javascript in the `webui` directory. This can happily be hosted on another webserver.

+ 7 - 1
Makefile

@@ -28,6 +28,10 @@ frontend-codestyle:
 frontend-unittests:
 	$(MAKE) -wC frontend unittests
 
+docs-check:
+	python3 docs/modules/ROOT/check_config_keys.py
+	python3 docs/modules/ROOT/check_chevron_links.py
+
 it:
 	$(MAKE) -wC integration-tests
 
@@ -45,6 +49,8 @@ lang-generate:
 
 generated-check: proto lang-generate
 	git diff --exit-code -- service/gen frontend/resources/scripts/gen lang/combined_output.json
+	@untracked="$$(git ls-files --others --exclude-standard -- service/gen frontend/resources/scripts/gen lang/combined_output.json)"; \
+	test -z "$$untracked" || { printf 'Untracked generated files:\n%s\n' "$$untracked"; exit 1; }
 
 dist:
 	echo "dist noop"
@@ -90,4 +96,4 @@ config-tool:
 devcheck:
 	python3 scripts/devcheck.py $(ARGS)
 
-.PHONY: proto proto-tools lang-generate generated-check default service windows-resources windows-msi frontend-unittests it devcheck
+.PHONY: proto proto-tools lang-generate generated-check default service windows-resources windows-msi frontend-unittests docs-check it devcheck

+ 7 - 6
frontend/Makefile

@@ -2,10 +2,11 @@ define delete-files
 	python3 -c "import shutil;shutil.rmtree('$(1)', ignore_errors=True)"
 endef
 
-codestyle:
-	npm install
-	npx eslint --fix main.js js/* resources/vue
-	npx stylelint style.css
+codestyle: deps
+	npm run lint
+
+codestyle-fix: deps
+	npm run lint:fix
 
 unittests: deps
 	npm test
@@ -14,11 +15,11 @@ clean:
 	$(call delete-files,dist)
 
 deps:
-	npm install
+	npm ci
 
 build:
 	npx vite build
 
 dist: deps clean build
 
-.PHONY: codestyle unittests
+.PHONY: codestyle codestyle-fix unittests deps build dist clean

+ 2 - 0
frontend/package.json

@@ -11,6 +11,8 @@
 		"stylelint-config-standard": "^40.0.0"
 	},
 	"scripts": {
+		"lint": "eslint main.js js/* resources/vue vite.config.mjs && stylelint \"style.css\" \"themes/**/*.css\"",
+		"lint:fix": "eslint --fix main.js js/* resources/vue vite.config.mjs && stylelint --fix \"style.css\" \"themes/**/*.css\"",
 		"test": "node --test resources/vue/components/*.test.mjs resources/vue/utils/*.test.mjs resources/vue/stores/*.test.mjs"
 	},
 	"author": "",

+ 12 - 12
frontend/vite.config.mjs

@@ -4,16 +4,16 @@ import Components from 'unplugin-vue-components/vite'
 
 export default defineConfig({
   resolve: {
-    dedupe: ['vue', 'vue-router'],
+    dedupe: ['vue', 'vue-router']
   },
   plugins: [
     Components({
       dirs: ['resources/vue/'],
       extensions: ['vue'],
       deep: true,
-      dts: false,
+      dts: false
     }),
-    vue(),
+    vue()
   ],
   build: {
     rolldownOptions: {
@@ -22,25 +22,25 @@ export default defineConfig({
           return
         }
         defaultHandler(level, log)
-      },
-    },
+      }
+    }
   },
   server: {
     proxy: {
       '/api': {
         target: 'http://localhost:1337',
         changeOrigin: true,
-        secure: false,
+        secure: false
       },
       '/theme.css': {
         target: 'http://localhost:1337',
         changeOrigin: true,
-        secure: false,
+        secure: false
       },
-      "/custom-webui": {
-        target: "http://localhost:1337",
-        changeOrigin: true,
+      '/custom-webui': {
+        target: 'http://localhost:1337',
+        changeOrigin: true
       }
-    },
-  },
+    }
+  }
 })

+ 0 - 8
integration-tests/.eslintrc.yml

@@ -1,8 +0,0 @@
-env:
-  browser: true
-  es2021: true
-extends: 'eslint:recommended'
-parserOptions:
-  ecmaVersion: 12
-  sourceType: module
-rules: {}

+ 9 - 3
integration-tests/Makefile

@@ -1,7 +1,13 @@
 default: test-install prep test-run
 
 test-install:
-	npm install --no-fund
+	npm ci --no-fund
+
+codestyle: test-install
+	npm run lint
+
+codestyle-fix: test-install
+	npm run lint:fix
 
 prep:
 ifneq ($(SKIP_WEBUI),1)
@@ -11,7 +17,7 @@ endif
 
 test-run:
     # GitHub Actions fails badly on the default timeout of 2000ms
-	npx mocha tests --recursive -t 10000
+	npm test
 
 find-flakey-tests:
 	echo "Running test-run infinately"
@@ -30,4 +36,4 @@ getsnapshot:
 	rm -rf /opt/OliveTin-snapshot/*
 	gh run download -D /opt/OliveTin-snapshot/
 
-.PHONY: default find-flakey-tests find-flakey-tests-inf prep
+.PHONY: default test-install codestyle codestyle-fix test-run find-flakey-tests find-flakey-tests-inf prep

+ 31 - 0
integration-tests/eslint.config.mjs

@@ -0,0 +1,31 @@
+import js from '@eslint/js'
+import globals from 'globals'
+
+export default [
+  {
+    ignores: [
+      'node_modules/**',
+      'tests/customJs/custom-webui/**'
+    ]
+  },
+  js.configs.recommended,
+  {
+    files: ['**/*.{js,mjs}'],
+    languageOptions: {
+      ecmaVersion: 'latest',
+      sourceType: 'module',
+      globals: {
+        ...globals.browser,
+        ...globals.node,
+        after: 'readonly',
+        afterEach: 'readonly',
+        before: 'readonly',
+        beforeEach: 'readonly',
+        describe: 'readonly',
+        it: 'readonly',
+        runner: 'readonly',
+        webdriver: 'readonly'
+      }
+    }
+  }
+]

+ 1 - 1
integration-tests/lib/elements.js

@@ -114,7 +114,7 @@ export function takeScreenshot (webdriver, title) {
     fs.mkdirSync('screenshots', { recursive: true });
 
   title = title.replaceAll('config: ', '')
-	title = title.replaceAll(/[\(\)\|\*\<\>\:]/g, "_")
+	title = title.replaceAll(/[()|*<>:]/g, '_')
 	title = title + '.failed-test'
 
     fs.writeFileSync('screenshots/' + title + '.png', img, 'base64')

+ 36 - 0
integration-tests/package-lock.json

@@ -12,8 +12,10 @@
         "wait-on": "^9.1.0"
       },
       "devDependencies": {
+        "@eslint/js": "^10.0.1",
         "chai": "^6.2.2",
         "eslint": "^10.10.0",
+        "globals": "^17.3.0",
         "mocha": "^12.0.0",
         "selenium-webdriver": "^4.49.0"
       }
@@ -141,6 +143,27 @@
         "node": "^20.19.0 || ^22.13.0 || >=24"
       }
     },
+    "node_modules/@eslint/js": {
+      "version": "10.0.1",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
+      "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      },
+      "peerDependencies": {
+        "eslint": "^10.0.0"
+      },
+      "peerDependenciesMeta": {
+        "eslint": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@eslint/object-schema": {
       "version": "3.0.5",
       "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
@@ -966,6 +989,19 @@
         "node": ">=10.13.0"
       }
     },
+    "node_modules/globals": {
+      "version": "17.12.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
+      "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/gopd": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",

+ 5 - 1
integration-tests/package.json

@@ -6,13 +6,17 @@
   "main": "index.js",
   "type": "module",
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "lint": "eslint tests lib scripts runner.mjs mochaSetup.mjs",
+    "lint:fix": "npm run lint -- --fix",
+    "test": "mocha tests --recursive -t 10000"
   },
   "author": "",
   "license": "AGPL-3.0-only",
   "devDependencies": {
+    "@eslint/js": "^10.0.1",
     "chai": "^6.2.2",
     "eslint": "^10.10.0",
+    "globals": "^17.3.0",
     "mocha": "^12.0.0",
     "selenium-webdriver": "^4.49.0"
   },

+ 1 - 1
integration-tests/scripts/find-flakey-tests-inf.mjs

@@ -104,7 +104,7 @@ function runMochaOnce () {
       try {
         report = JSON.parse(readFileSync(reportPath, 'utf8'))
       } catch {
-        report = null
+        // Keep the default null report when Mocha did not produce valid JSON.
       }
 
       try {

+ 1 - 1
integration-tests/tests/argumentActionFlash/argumentActionFlash.mjs

@@ -28,7 +28,7 @@ async function waitForActionSuccessFlash (actionTitle) {
         const button = await getActionButton(webdriver, actionTitle)
         const classAttr = await button.getAttribute('class')
         return classAttr && classAttr.includes('action-success')
-      } catch (e) {
+      } catch {
         return false
       }
     }),

+ 1 - 1
integration-tests/tests/argumentIdCollision/argumentIdCollision.mjs

@@ -45,7 +45,7 @@ async function waitForTerminalOutput (expectedSubstring) {
 
         const output = await getTerminalBuffer()
         return output && output.includes(expectedSubstring)
-      } catch (e) {
+      } catch {
         return false
       }
     }),

+ 1 - 2
integration-tests/tests/authRequireGuestsToLogin/authRequireGuestsToLogin.mjs

@@ -1,8 +1,7 @@
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
-import { By, until } from 'selenium-webdriver'
+import { By } from 'selenium-webdriver'
 import {
-  getRootAndWait,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 

+ 1 - 1
integration-tests/tests/checkbox/checkbox.mjs

@@ -47,7 +47,7 @@ async function waitForTerminalOutput(expectedValue) {
         }
 
         return output.trim().includes(`Checkbox value: ${expectedValue}`)
-      } catch (e) {
+      } catch {
         return false
       }
     }),

+ 1 - 1
integration-tests/tests/checklist/checklist.mjs

@@ -44,7 +44,7 @@ async function pollTerminal(matcher, timeoutMs = DEFAULT_UI_WAIT_MS) {
         }
 
         return matcher(output.trim())
-      } catch (e) {
+      } catch {
         return false
       }
     }),

+ 1 - 1
integration-tests/tests/confirmation/confirmation.mjs

@@ -59,7 +59,7 @@ async function waitForTerminalOutput (expectedSubstring) {
 
         const output = await getTerminalBuffer()
         return output && output.includes(expectedSubstring)
-      } catch (e) {
+      } catch {
         return false
       }
     }),

+ 1 - 1
integration-tests/tests/dashboardsWithBasicFieldsets/dashboardsWithBasicFieldsets.mjs

@@ -1,6 +1,6 @@
 import { describe, it, before, after } from 'mocha'
 import { expect, assert } from 'chai'
-import { By, until, Condition } from 'selenium-webdriver'
+import { By } from 'selenium-webdriver'
 //import * as waitOn from 'wait-on'
 import {
   getRootAndWait,

+ 0 - 1
integration-tests/tests/emptyDashboardsAreHidden/emptyDashboardsAreHidden.mjs

@@ -1,6 +1,5 @@
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
-import { By, until, Condition } from 'selenium-webdriver'
 //import * as waitOn from 'wait-on'
 import {
   getRootAndWait,

+ 1 - 3
integration-tests/tests/hiddenFooter/hiddenFooter.mjs

@@ -2,9 +2,7 @@ import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 
 import { By } from 'selenium-webdriver'
-import { 
-  getRootAndWait, 
-  getActionButtons,
+import {
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 

+ 2 - 5
integration-tests/tests/hiddenNav/hiddenNav.mjs

@@ -1,8 +1,5 @@
-import { expect } from 'chai'
-import { By } from 'selenium-webdriver'
-import { 
-  getRootAndWait, 
-  getActionButtons,
+import {
+  getRootAndWait,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 

+ 1 - 1
integration-tests/tests/justification/justification.mjs

@@ -88,7 +88,7 @@ async function waitForTerminalOutput (expectedSubstring) {
 
         const output = await getTerminalBuffer()
         return output && output.includes(expectedSubstring)
-      } catch (e) {
+      } catch {
         return false
       }
     }),

+ 5 - 6
integration-tests/tests/localAuth/localAuth.mjs

@@ -1,8 +1,7 @@
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
-import { By, until, Condition } from 'selenium-webdriver'
+import { By, until } from 'selenium-webdriver'
 import {
-  getRootAndWait,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 
@@ -47,7 +46,7 @@ describe('config: localAuth', function () {
     // Check if any login-related elements are present
     const bodyText = await webdriver.findElement(By.tagName('body')).getText()
     console.log('Login page content:', bodyText.substring(0, 300))
-    
+
     // For now, just verify we can navigate to the login page
     // The page content rendering is a separate frontend issue
     console.log('Login page navigation successful')
@@ -67,11 +66,11 @@ describe('config: localAuth', function () {
 
     if (usernameFields.length > 0 && passwordFields.length > 0 && loginButtons.length > 0) {
       console.log('Login form found, attempting login')
-      
+
       // Fill in credentials
       await usernameFields[0].clear()
       await usernameFields[0].sendKeys('testuser')
-      
+
       await passwordFields[0].clear()
       await passwordFields[0].sendKeys('testpass123')
 
@@ -100,4 +99,4 @@ describe('config: localAuth', function () {
       console.log('Login form not found - skipping login test')
     }
   })
-})
+})

+ 1 - 2
integration-tests/tests/logPersistence/logPersistence.mjs

@@ -5,7 +5,6 @@ import fs from 'fs'
 import path from 'path'
 import {
   getRootAndWait,
-  getActionButtons,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 
@@ -210,7 +209,7 @@ describe('config: logPersistence', function () {
           const text = await body.getText()
           // The log should contain the output from the echo command
           return text.includes('Hello from persisted log test') || text.includes(firstExecutionTrackingId)
-        } catch (e) {
+        } catch {
           return false
         }
       }),

+ 1 - 1
integration-tests/tests/multipleDropdowns/multipleDropdowns.mjs

@@ -1,6 +1,6 @@
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
-import { By, until, Condition, Key } from 'selenium-webdriver'
+import { By, Condition, Key } from 'selenium-webdriver'
 import {
   getRootAndWait,
   getActionButtons,

+ 8 - 15
integration-tests/tests/oauthLoginGithub/githubOAuth.mjs

@@ -1,8 +1,7 @@
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
-import { By, until, Condition } from 'selenium-webdriver'
+import { By, until } from 'selenium-webdriver'
 import {
-  getRootAndWait,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 
@@ -56,14 +55,14 @@ describe('config: githubOAuth', function () {
     // Button may show "Login with GitHub" or "Login with undefined" depending on provider.name vs provider.title
     // We'll check for the presence of the button and verify it's in the OAuth section
     expect(githubButtons.length).to.be.greaterThan(0, 'At least one OAuth button should be present')
-    
+
     // The first button should be GitHub since it's the only provider in the config
     const githubButton = githubButtons[0]
     const buttonText = await githubButton.getText()
-    
+
     // Button should contain "Login with" and the provider should be configured as GitHub
     expect(buttonText).to.include('Login with', 'Button should have "Login with" prefix')
-    
+
     console.log('GitHub OAuth button found with text:', buttonText)
   })
 
@@ -78,16 +77,14 @@ describe('config: githubOAuth', function () {
     // Since the test config only has one provider (GitHub), we can use the first button
     const githubButtons = await webdriver.findElements(By.css('.oauth-button'))
     expect(githubButtons.length).to.be.greaterThan(0, 'At least one OAuth button should be present')
-    
+
     const githubButton = githubButtons[0]
     const buttonText = await githubButton.getText()
     console.log('Button text:', buttonText)
-    
+
     // Verify it's the GitHub button (should contain "github" in the text)
     expect(buttonText.toLowerCase()).to.include('github', 'Button should be GitHub OAuth button')
 
-    // Check for provider icon (if present)
-    const providerIcons = await githubButton.findElements(By.css('.provider-icon'))
     const providerNames = await githubButton.findElements(By.css('.provider-name'))
     // Provider name may show "GitHub" (from title) or be undefined (if using name field)
     // Just verify the structure is present
@@ -111,11 +108,8 @@ describe('config: githubOAuth', function () {
     // Find GitHub OAuth button (should be the first/only one in our test config)
     const githubButtons = await webdriver.findElements(By.css('.oauth-button'))
     expect(githubButtons.length).to.be.greaterThan(0, 'OAuth button should be present')
-    
-    const githubButton = githubButtons[0]
 
-    // Get the current URL before clicking
-    const initialUrl = await webdriver.getCurrentUrl()
+    const githubButton = githubButtons[0]
 
     // Click the button
     await githubButton.click()
@@ -124,7 +118,7 @@ describe('config: githubOAuth', function () {
     // Since we can't actually complete OAuth flow, we check that the button
     // click handler is set up correctly by verifying the button exists and is clickable
     // In a real scenario, this would redirect to GitHub's OAuth page
-    
+
     // Give a small delay to allow any navigation to start
     await new Promise(resolve => setTimeout(resolve, 1000))
 
@@ -133,4 +127,3 @@ describe('config: githubOAuth', function () {
     console.log('GitHub OAuth button click verified (redirect would happen in production)')
   })
 })
-

+ 1 - 2
integration-tests/tests/onlyDashboards/onlyDashboards.mjs

@@ -6,7 +6,6 @@ import {
   getActionButtons,
   getNavigationLinks,
   openSidebar,
-  closeSidebar,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 
@@ -43,7 +42,7 @@ describe('config: onlyDashboards', function () {
     const firstDashboardLink = await webdriver.findElement(By.css('li[title="My Dashboard"]'), 'The first dashboard link should be present')
     assert.isNotNull(firstDashboardLink, 'First dashboard link should not be null')
     assert.isTrue(await firstDashboardLink.isDisplayed(), 'First dashboard link should be displayed')
-    
+
     const actionButtonsOnDashboard = await getActionButtons()
     assert.isArray(actionButtonsOnDashboard, 'Action buttons on dashboard should be an array')
     assert.lengthOf(actionButtonsOnDashboard, 3, 'Action buttons on dashboard should have 3 buttons')

+ 1 - 3
integration-tests/tests/sleep/sleep.mjs

@@ -1,9 +1,7 @@
-import * as process from 'node:process'
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
-import { By, Condition } from 'selenium-webdriver'
+import { By } from 'selenium-webdriver'
 import {
-  takeScreenshot,
   takeScreenshotOnFailure,
   findExecutionDialog,
   requireExecutionDialogStatus,

+ 4 - 5
integration-tests/tests/stdoutMostRecentExecution/stdoutMostRecentExecution.mjs

@@ -3,7 +3,6 @@ import { expect } from 'chai'
 import { By, Condition } from 'selenium-webdriver'
 import {
   getRootAndWait,
-  getActionButtons,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 
@@ -116,13 +115,13 @@ describe('config: stdout-most-recent-execution', function () {
           // Output should change from initial state and contain actual output
           // (not "Waiting...", "No execution found", or the same as initialText)
           const hasChanged = newText !== initialText
-          const hasValidOutput = newText && 
-                                 !newText.includes('Waiting...') && 
-                                 !newText.includes('No execution found') && 
+          const hasValidOutput = newText &&
+                                 !newText.includes('Waiting...') &&
+                                 !newText.includes('No execution found') &&
                                  !newText.includes('Error:') &&
                                  newText.trim().length > 0
           return hasChanged && hasValidOutput
-        } catch (e) {
+        } catch {
           return false
         }
       }),

+ 4 - 1
service/Makefile

@@ -44,6 +44,9 @@ unittests:
 unittests-fast:
 	go test ./... -count=1
 
+unittests-race:
+	go test -race ./... -count=1
+
 find-flakey-tests:
 	echo "Running unittests-fast infinitely"
 	sh -c "while $(MAKE) unittests-fast; do :; done"
@@ -60,4 +63,4 @@ proto-tools:
 
 go-tools-all: go-tools proto-tools
 
-.PHONY: codestyle go-tools proto-tools go-tools-all unittests unittests-fast find-flakey-tests find-flakey-tests-inf
+.PHONY: codestyle go-tools proto-tools go-tools-all unittests unittests-fast unittests-race find-flakey-tests find-flakey-tests-inf

+ 35 - 9
service/internal/executor/executor.go

@@ -153,6 +153,30 @@ type InternalLogEntry struct {
 	TimedOut            bool
 }
 
+func cloneActionBinding(binding *ActionBinding) *ActionBinding {
+	if binding == nil {
+		return nil
+	}
+
+	cloned := *binding
+	cloned.OnDashboards = slices.Clone(binding.OnDashboards)
+
+	return &cloned
+}
+
+func cloneInternalLogEntry(entry *InternalLogEntry) *InternalLogEntry {
+	if entry == nil {
+		return nil
+	}
+
+	cloned := *entry
+	cloned.Arguments = maps.Clone(entry.Arguments)
+	cloned.Tags = slices.Clone(entry.Tags)
+	cloned.Binding = cloneActionBinding(entry.Binding)
+
+	return &cloned
+}
+
 // .Binding can be nil, so we need to handle that.
 func (e *InternalLogEntry) GetBindingId() string {
 	if e.Binding == nil {
@@ -273,7 +297,7 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int
 
 	if totalLogCount > 0 {
 		for i := startIndex; i >= endIndex; i-- {
-			trackingIds = append(trackingIds, e.logs[e.logsTrackingIdsByDate[i]])
+			trackingIds = append(trackingIds, cloneInternalLogEntry(e.logs[e.logsTrackingIdsByDate[i]]))
 		}
 	}
 
@@ -303,7 +327,7 @@ func (e *Executor) filterLogsByACL(cfg *config.Config, user *authpublic.Authenti
 		entry := e.logs[trackingId]
 
 		if shouldIncludeLogEntry(cfg, user, entry, filterDate, hasDateFilter) {
-			filtered = append(filtered, entry)
+			filtered = append(filtered, cloneInternalLogEntry(entry))
 		}
 	}
 
@@ -399,26 +423,28 @@ func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *authpublic.Aut
 
 func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
 	e.logmutex.RLock()
+	defer e.logmutex.RUnlock()
 
 	entry, found := e.logs[trackingID]
-
-	e.logmutex.RUnlock()
-
-	return entry, found
+	return cloneInternalLogEntry(entry), found
 }
 
 func (e *Executor) GetLogsByBindingId(bindingId string) []*InternalLogEntry {
 	e.logmutex.RLock()
+	defer e.logmutex.RUnlock()
 
 	logs, found := e.LogsByBindingId[bindingId]
 
-	e.logmutex.RUnlock()
-
 	if !found {
 		return make([]*InternalLogEntry, 0)
 	}
 
-	return logs
+	cloned := make([]*InternalLogEntry, 0, len(logs))
+	for _, entry := range logs {
+		cloned = append(cloned, cloneInternalLogEntry(entry))
+	}
+
+	return cloned
 }
 
 // shouldCountExecution checks if a log entry should be counted for rate limiting.

+ 33 - 0
service/internal/executor/executor_test.go

@@ -38,6 +38,39 @@ func testingExecutor() (*Executor, *config.Config) {
 	return e, cfg
 }
 
+func TestGetLogReturnsDefensiveCopy(t *testing.T) {
+	e := DefaultExecutor(config.DefaultConfig())
+	e.logs["tracking-id"] = &InternalLogEntry{
+		Arguments: map[string]string{"message": "original"},
+		Output:    "original",
+		Tags:      []string{"original"},
+		Binding: &ActionBinding{
+			ID: "original-binding",
+			OnDashboards: []DashboardNavigationTarget{
+				{Title: "original"},
+			},
+		},
+	}
+
+	entry, found := e.GetLog("tracking-id")
+	require.True(t, found)
+
+	entry.Arguments["message"] = "changed"
+	entry.Output = "changed"
+	entry.Tags[0] = "changed"
+	entry.Binding.ID = "changed-binding"
+	entry.Binding.OnDashboards[0].Title = "changed"
+
+	stored, found := e.GetLog("tracking-id")
+	require.True(t, found)
+	assert.Equal(t, "original", stored.Arguments["message"])
+	assert.Equal(t, "original", stored.Output)
+	assert.Equal(t, []string{"original"}, stored.Tags)
+	require.NotNil(t, stored.Binding)
+	assert.Equal(t, "original-binding", stored.Binding.ID)
+	assert.Equal(t, []DashboardNavigationTarget{{Title: "original"}}, stored.Binding.OnDashboards)
+}
+
 func TestCreateExecutorAndExec(t *testing.T) {
 	e, cfg := testingExecutor()
 

+ 6 - 2
service/internal/filehelper/file_change_notify.go

@@ -273,13 +273,17 @@ func processDebounce(ctx *watchContext) {
 	if logEntry.callbackComplete || logEntry.callbackWrapper == nil {
 		log.Debugf("fsnotify event callback queued within debounce delay: %v", ctx.filename)
 
+		callback := ctx.callback
+		eventName := ctx.event.Name
 		logEntry.callbackComplete = false
 		logEntry.callbackWrapper = time.AfterFunc(debounceDelay, func() {
-			log.Debugf("fsnotify event callback being fired: %v", ctx.filename)
+			log.Debugf("fsnotify event callback being fired: %v", eventName)
 
-			ctx.callback(ctx.event.Name)
+			callback(eventName)
 
+			debounceWriteLogMutex.Lock()
 			logEntry.callbackComplete = true
+			debounceWriteLogMutex.Unlock()
 		})
 	} else {
 		log.Debugf("fsnotify event suppressed because it's within the debounce delay: %v", ctx.filename)

+ 37 - 0
service/internal/filehelper/file_change_notify_test.go

@@ -0,0 +1,37 @@
+package filehelper
+
+import (
+	"testing"
+	"time"
+
+	"github.com/fsnotify/fsnotify"
+	"github.com/stretchr/testify/require"
+)
+
+func TestProcessDebounceCapturesEventName(t *testing.T) {
+	debounceWriteLogMutex.Lock()
+	debounceWriteLog = make(map[string]*FsNotifyLogEntry)
+	debounceWriteLogMutex.Unlock()
+
+	callbackNames := make(chan string, 1)
+	firstEvent := fsnotify.Event{Name: "first"}
+	ctx := &watchContext{
+		callback: func(filename string) {
+			callbackNames <- filename
+		},
+		event:    &firstEvent,
+		filename: t.Name(),
+	}
+
+	processDebounce(ctx)
+
+	secondEvent := fsnotify.Event{Name: "second"}
+	ctx.event = &secondEvent
+
+	select {
+	case callbackName := <-callbackNames:
+		require.Equal(t, firstEvent.Name, callbackName)
+	case <-time.After(time.Second):
+		t.Fatal("debounced callback did not run")
+	}
+}