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

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:
   push:
     paths:
     paths:
       - '.github/workflows/codestyle.yml'
       - '.github/workflows/codestyle.yml'
+      - 'Makefile'
+      - 'frontend/**'
+      - 'integration-tests/**'
+      - 'proto/**'
+      - 'service/**'
+  pull_request:
+    branches:
+      - next
+    paths:
+      - '.github/workflows/codestyle.yml'
+      - 'Makefile'
       - 'frontend/**'
       - 'frontend/**'
       - 'integration-tests/**'
       - 'integration-tests/**'
       - 'proto/**'
       - 'proto/**'
@@ -35,9 +46,16 @@ jobs:
         uses: actions/setup-node@v4
         uses: actions/setup-node@v4
         with:
         with:
           node-version: '22'
           node-version: '22'
+          cache: 'npm'
+          cache-dependency-path: |
+            frontend/package-lock.json
+            integration-tests/package-lock.json
 
 
       - name: frontend
       - name: frontend
         run: make -wC frontend codestyle
         run: make -wC frontend codestyle
 
 
       - name: frontend unit tests
       - name: frontend unit tests
         run: make -wC frontend unittests
         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:
   push:
     paths:
     paths:
       - 'docs/**'
       - 'docs/**'
+      - 'service/internal/config/config.go'
       - 'local-antora-playbook.yml'
       - 'local-antora-playbook.yml'
       - 'local-antora-playbook-ci.yml'
       - 'local-antora-playbook-ci.yml'
       - '.github/workflows/docs-antora.yml'
       - '.github/workflows/docs-antora.yml'
   pull_request:
   pull_request:
     paths:
     paths:
       - 'docs/**'
       - 'docs/**'
+      - 'service/internal/config/config.go'
       - 'local-antora-playbook.yml'
       - 'local-antora-playbook.yml'
       - 'local-antora-playbook-ci.yml'
       - 'local-antora-playbook-ci.yml'
       - '.github/workflows/docs-antora.yml'
       - '.github/workflows/docs-antora.yml'

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

@@ -12,6 +12,7 @@ on:
       - 'lang/**'
       - 'lang/**'
       - 'proto/**'
       - 'proto/**'
       - 'service/gen/**'
       - 'service/gen/**'
+      - 'service/generate.go'
       - 'service/go.mod'
       - 'service/go.mod'
       - 'service/go.sum'
       - 'service/go.sum'
       - 'service/Makefile'
       - 'service/Makefile'
@@ -26,6 +27,7 @@ on:
       - 'lang/**'
       - 'lang/**'
       - 'proto/**'
       - 'proto/**'
       - 'service/gen/**'
       - 'service/gen/**'
+      - 'service/generate.go'
       - 'service/go.mod'
       - 'service/go.mod'
       - 'service/go.sum'
       - 'service/go.sum'
       - 'service/Makefile'
       - 'service/Makefile'
@@ -40,6 +42,8 @@ jobs:
     steps:
     steps:
       - name: Checkout
       - name: Checkout
         uses: actions/checkout@v4
         uses: actions/checkout@v4
+        with:
+          persist-credentials: false
 
 
       - name: Setup Go
       - name: Setup Go
         uses: actions/setup-go@v5
         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
   - repo: local
     hooks:
     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
       - id: service-codestyle
         name: service-codestyle
         name: service-codestyle
         entry: make 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`
   - From repo root: `cd service && make unittests`
 - Code style (after editing code in `service/`):
 - 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`)
   - 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):
 - Integration tests (Mocha + Selenium):
   - All tests: `make it` (from repo root; builds webui + service binary, then runs Mocha)
   - 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`
   - 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).
 * 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.
 * 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 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 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.
 * 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:
 frontend-unittests:
 	$(MAKE) -wC 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:
 it:
 	$(MAKE) -wC integration-tests
 	$(MAKE) -wC integration-tests
 
 
@@ -45,6 +49,8 @@ lang-generate:
 
 
 generated-check: proto lang-generate
 generated-check: proto lang-generate
 	git diff --exit-code -- service/gen frontend/resources/scripts/gen lang/combined_output.json
 	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:
 dist:
 	echo "dist noop"
 	echo "dist noop"
@@ -90,4 +96,4 @@ config-tool:
 devcheck:
 devcheck:
 	python3 scripts/devcheck.py $(ARGS)
 	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)"
 	python3 -c "import shutil;shutil.rmtree('$(1)', ignore_errors=True)"
 endef
 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
 unittests: deps
 	npm test
 	npm test
@@ -14,11 +15,11 @@ clean:
 	$(call delete-files,dist)
 	$(call delete-files,dist)
 
 
 deps:
 deps:
-	npm install
+	npm ci
 
 
 build:
 build:
 	npx vite build
 	npx vite build
 
 
 dist: deps clean 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"
 		"stylelint-config-standard": "^40.0.0"
 	},
 	},
 	"scripts": {
 	"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"
 		"test": "node --test resources/vue/components/*.test.mjs resources/vue/utils/*.test.mjs resources/vue/stores/*.test.mjs"
 	},
 	},
 	"author": "",
 	"author": "",

+ 12 - 12
frontend/vite.config.mjs

@@ -4,16 +4,16 @@ import Components from 'unplugin-vue-components/vite'
 
 
 export default defineConfig({
 export default defineConfig({
   resolve: {
   resolve: {
-    dedupe: ['vue', 'vue-router'],
+    dedupe: ['vue', 'vue-router']
   },
   },
   plugins: [
   plugins: [
     Components({
     Components({
       dirs: ['resources/vue/'],
       dirs: ['resources/vue/'],
       extensions: ['vue'],
       extensions: ['vue'],
       deep: true,
       deep: true,
-      dts: false,
+      dts: false
     }),
     }),
-    vue(),
+    vue()
   ],
   ],
   build: {
   build: {
     rolldownOptions: {
     rolldownOptions: {
@@ -22,25 +22,25 @@ export default defineConfig({
           return
           return
         }
         }
         defaultHandler(level, log)
         defaultHandler(level, log)
-      },
-    },
+      }
+    }
   },
   },
   server: {
   server: {
     proxy: {
     proxy: {
       '/api': {
       '/api': {
         target: 'http://localhost:1337',
         target: 'http://localhost:1337',
         changeOrigin: true,
         changeOrigin: true,
-        secure: false,
+        secure: false
       },
       },
       '/theme.css': {
       '/theme.css': {
         target: 'http://localhost:1337',
         target: 'http://localhost:1337',
         changeOrigin: true,
         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
 default: test-install prep test-run
 
 
 test-install:
 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:
 prep:
 ifneq ($(SKIP_WEBUI),1)
 ifneq ($(SKIP_WEBUI),1)
@@ -11,7 +17,7 @@ endif
 
 
 test-run:
 test-run:
     # GitHub Actions fails badly on the default timeout of 2000ms
     # GitHub Actions fails badly on the default timeout of 2000ms
-	npx mocha tests --recursive -t 10000
+	npm test
 
 
 find-flakey-tests:
 find-flakey-tests:
 	echo "Running test-run infinately"
 	echo "Running test-run infinately"
@@ -30,4 +36,4 @@ getsnapshot:
 	rm -rf /opt/OliveTin-snapshot/*
 	rm -rf /opt/OliveTin-snapshot/*
 	gh run download -D /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 });
     fs.mkdirSync('screenshots', { recursive: true });
 
 
   title = title.replaceAll('config: ', '')
   title = title.replaceAll('config: ', '')
-	title = title.replaceAll(/[\(\)\|\*\<\>\:]/g, "_")
+	title = title.replaceAll(/[()|*<>:]/g, '_')
 	title = title + '.failed-test'
 	title = title + '.failed-test'
 
 
     fs.writeFileSync('screenshots/' + title + '.png', img, 'base64')
     fs.writeFileSync('screenshots/' + title + '.png', img, 'base64')

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

@@ -12,8 +12,10 @@
         "wait-on": "^9.1.0"
         "wait-on": "^9.1.0"
       },
       },
       "devDependencies": {
       "devDependencies": {
+        "@eslint/js": "^10.0.1",
         "chai": "^6.2.2",
         "chai": "^6.2.2",
         "eslint": "^10.10.0",
         "eslint": "^10.10.0",
+        "globals": "^17.3.0",
         "mocha": "^12.0.0",
         "mocha": "^12.0.0",
         "selenium-webdriver": "^4.49.0"
         "selenium-webdriver": "^4.49.0"
       }
       }
@@ -141,6 +143,27 @@
         "node": "^20.19.0 || ^22.13.0 || >=24"
         "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": {
     "node_modules/@eslint/object-schema": {
       "version": "3.0.5",
       "version": "3.0.5",
       "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
       "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
@@ -966,6 +989,19 @@
         "node": ">=10.13.0"
         "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": {
     "node_modules/gopd": {
       "version": "1.2.0",
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
       "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",

+ 5 - 1
integration-tests/package.json

@@ -6,13 +6,17 @@
   "main": "index.js",
   "main": "index.js",
   "type": "module",
   "type": "module",
   "scripts": {
   "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": "",
   "author": "",
   "license": "AGPL-3.0-only",
   "license": "AGPL-3.0-only",
   "devDependencies": {
   "devDependencies": {
+    "@eslint/js": "^10.0.1",
     "chai": "^6.2.2",
     "chai": "^6.2.2",
     "eslint": "^10.10.0",
     "eslint": "^10.10.0",
+    "globals": "^17.3.0",
     "mocha": "^12.0.0",
     "mocha": "^12.0.0",
     "selenium-webdriver": "^4.49.0"
     "selenium-webdriver": "^4.49.0"
   },
   },

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

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

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

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

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

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

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

@@ -1,8 +1,7 @@
 import { describe, it, before, after } from 'mocha'
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { expect } from 'chai'
-import { By, until } from 'selenium-webdriver'
+import { By } from 'selenium-webdriver'
 import {
 import {
-  getRootAndWait,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 } 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}`)
         return output.trim().includes(`Checkbox value: ${expectedValue}`)
-      } catch (e) {
+      } catch {
         return false
         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())
         return matcher(output.trim())
-      } catch (e) {
+      } catch {
         return false
         return false
       }
       }
     }),
     }),

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

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

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

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

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

@@ -1,6 +1,5 @@
 import { describe, it, before, after } from 'mocha'
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { expect } from 'chai'
-import { By, until, Condition } from 'selenium-webdriver'
 //import * as waitOn from 'wait-on'
 //import * as waitOn from 'wait-on'
 import {
 import {
   getRootAndWait,
   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 { expect } from 'chai'
 
 
 import { By } from 'selenium-webdriver'
 import { By } from 'selenium-webdriver'
-import { 
-  getRootAndWait, 
-  getActionButtons,
+import {
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 } 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,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 } from '../../lib/elements.js'
 
 

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

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

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

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

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

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

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

@@ -1,8 +1,7 @@
 import { describe, it, before, after } from 'mocha'
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { expect } from 'chai'
-import { By, until, Condition } from 'selenium-webdriver'
+import { By, until } from 'selenium-webdriver'
 import {
 import {
-  getRootAndWait,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 } 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
     // 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
     // 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')
     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
     // The first button should be GitHub since it's the only provider in the config
     const githubButton = githubButtons[0]
     const githubButton = githubButtons[0]
     const buttonText = await githubButton.getText()
     const buttonText = await githubButton.getText()
-    
+
     // Button should contain "Login with" and the provider should be configured as GitHub
     // 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')
     expect(buttonText).to.include('Login with', 'Button should have "Login with" prefix')
-    
+
     console.log('GitHub OAuth button found with text:', buttonText)
     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
     // Since the test config only has one provider (GitHub), we can use the first button
     const githubButtons = await webdriver.findElements(By.css('.oauth-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')
     expect(githubButtons.length).to.be.greaterThan(0, 'At least one OAuth button should be present')
-    
+
     const githubButton = githubButtons[0]
     const githubButton = githubButtons[0]
     const buttonText = await githubButton.getText()
     const buttonText = await githubButton.getText()
     console.log('Button text:', buttonText)
     console.log('Button text:', buttonText)
-    
+
     // Verify it's the GitHub button (should contain "github" in the text)
     // Verify it's the GitHub button (should contain "github" in the text)
     expect(buttonText.toLowerCase()).to.include('github', 'Button should be GitHub OAuth button')
     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'))
     const providerNames = await githubButton.findElements(By.css('.provider-name'))
     // Provider name may show "GitHub" (from title) or be undefined (if using name field)
     // Provider name may show "GitHub" (from title) or be undefined (if using name field)
     // Just verify the structure is present
     // 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)
     // Find GitHub OAuth button (should be the first/only one in our test config)
     const githubButtons = await webdriver.findElements(By.css('.oauth-button'))
     const githubButtons = await webdriver.findElements(By.css('.oauth-button'))
     expect(githubButtons.length).to.be.greaterThan(0, 'OAuth button should be present')
     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
     // Click the button
     await githubButton.click()
     await githubButton.click()
@@ -124,7 +118,7 @@ describe('config: githubOAuth', function () {
     // Since we can't actually complete OAuth flow, we check that the button
     // 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
     // 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
     // In a real scenario, this would redirect to GitHub's OAuth page
-    
+
     // Give a small delay to allow any navigation to start
     // Give a small delay to allow any navigation to start
     await new Promise(resolve => setTimeout(resolve, 1000))
     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)')
     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,
   getActionButtons,
   getNavigationLinks,
   getNavigationLinks,
   openSidebar,
   openSidebar,
-  closeSidebar,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 } 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')
     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.isNotNull(firstDashboardLink, 'First dashboard link should not be null')
     assert.isTrue(await firstDashboardLink.isDisplayed(), 'First dashboard link should be displayed')
     assert.isTrue(await firstDashboardLink.isDisplayed(), 'First dashboard link should be displayed')
-    
+
     const actionButtonsOnDashboard = await getActionButtons()
     const actionButtonsOnDashboard = await getActionButtons()
     assert.isArray(actionButtonsOnDashboard, 'Action buttons on dashboard should be an array')
     assert.isArray(actionButtonsOnDashboard, 'Action buttons on dashboard should be an array')
     assert.lengthOf(actionButtonsOnDashboard, 3, 'Action buttons on dashboard should have 3 buttons')
     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 { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { expect } from 'chai'
-import { By, Condition } from 'selenium-webdriver'
+import { By } from 'selenium-webdriver'
 import {
 import {
-  takeScreenshot,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
   findExecutionDialog,
   findExecutionDialog,
   requireExecutionDialogStatus,
   requireExecutionDialogStatus,

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

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

+ 4 - 1
service/Makefile

@@ -44,6 +44,9 @@ unittests:
 unittests-fast:
 unittests-fast:
 	go test ./... -count=1
 	go test ./... -count=1
 
 
+unittests-race:
+	go test -race ./... -count=1
+
 find-flakey-tests:
 find-flakey-tests:
 	echo "Running unittests-fast infinitely"
 	echo "Running unittests-fast infinitely"
 	sh -c "while $(MAKE) unittests-fast; do :; done"
 	sh -c "while $(MAKE) unittests-fast; do :; done"
@@ -60,4 +63,4 @@ proto-tools:
 
 
 go-tools-all: go-tools 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
 	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.
 // .Binding can be nil, so we need to handle that.
 func (e *InternalLogEntry) GetBindingId() string {
 func (e *InternalLogEntry) GetBindingId() string {
 	if e.Binding == nil {
 	if e.Binding == nil {
@@ -273,7 +297,7 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int
 
 
 	if totalLogCount > 0 {
 	if totalLogCount > 0 {
 		for i := startIndex; i >= endIndex; i-- {
 		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]
 		entry := e.logs[trackingId]
 
 
 		if shouldIncludeLogEntry(cfg, user, entry, filterDate, hasDateFilter) {
 		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) {
 func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
 	e.logmutex.RLock()
 	e.logmutex.RLock()
+	defer e.logmutex.RUnlock()
 
 
 	entry, found := e.logs[trackingID]
 	entry, found := e.logs[trackingID]
-
-	e.logmutex.RUnlock()
-
-	return entry, found
+	return cloneInternalLogEntry(entry), found
 }
 }
 
 
 func (e *Executor) GetLogsByBindingId(bindingId string) []*InternalLogEntry {
 func (e *Executor) GetLogsByBindingId(bindingId string) []*InternalLogEntry {
 	e.logmutex.RLock()
 	e.logmutex.RLock()
+	defer e.logmutex.RUnlock()
 
 
 	logs, found := e.LogsByBindingId[bindingId]
 	logs, found := e.LogsByBindingId[bindingId]
 
 
-	e.logmutex.RUnlock()
-
 	if !found {
 	if !found {
 		return make([]*InternalLogEntry, 0)
 		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.
 // 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
 	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) {
 func TestCreateExecutorAndExec(t *testing.T) {
 	e, cfg := testingExecutor()
 	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 {
 	if logEntry.callbackComplete || logEntry.callbackWrapper == nil {
 		log.Debugf("fsnotify event callback queued within debounce delay: %v", ctx.filename)
 		log.Debugf("fsnotify event callback queued within debounce delay: %v", ctx.filename)
 
 
+		callback := ctx.callback
+		eventName := ctx.event.Name
 		logEntry.callbackComplete = false
 		logEntry.callbackComplete = false
 		logEntry.callbackWrapper = time.AfterFunc(debounceDelay, func() {
 		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
 			logEntry.callbackComplete = true
+			debounceWriteLogMutex.Unlock()
 		})
 		})
 	} else {
 	} else {
 		log.Debugf("fsnotify event suppressed because it's within the debounce delay: %v", ctx.filename)
 		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")
+	}
+}