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>
This commit is contained in:
jamesread
2026-09-10 16:54:12 +01:00
co-authored by Cursor
41 changed files with 328 additions and 97 deletions
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
},
},
}
}
})
-8
View File
@@ -1,8 +0,0 @@
env:
browser: true
es2021: true
extends: 'eslint:recommended'
parserOptions:
ecmaVersion: 12
sourceType: module
rules: {}
+9 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
},
@@ -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 {
@@ -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
}
}),
@@ -45,7 +45,7 @@ async function waitForTerminalOutput (expectedSubstring) {
const output = await getTerminalBuffer()
return output && output.includes(expectedSubstring)
} catch (e) {
} catch {
return false
}
}),
@@ -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'
@@ -47,7 +47,7 @@ async function waitForTerminalOutput(expectedValue) {
}
return output.trim().includes(`Checkbox value: ${expectedValue}`)
} catch (e) {
} catch {
return false
}
}),
@@ -44,7 +44,7 @@ async function pollTerminal(matcher, timeoutMs = DEFAULT_UI_WAIT_MS) {
}
return matcher(output.trim())
} catch (e) {
} catch {
return false
}
}),
@@ -59,7 +59,7 @@ async function waitForTerminalOutput (expectedSubstring) {
const output = await getTerminalBuffer()
return output && output.includes(expectedSubstring)
} catch (e) {
} catch {
return false
}
}),
@@ -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,
@@ -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,
@@ -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'
@@ -1,8 +1,5 @@
import { expect } from 'chai'
import { By } from 'selenium-webdriver'
import {
getRootAndWait,
getActionButtons,
import {
getRootAndWait,
takeScreenshotOnFailure,
} from '../../lib/elements.js'
@@ -88,7 +88,7 @@ async function waitForTerminalOutput (expectedSubstring) {
const output = await getTerminalBuffer()
return output && output.includes(expectedSubstring)
} catch (e) {
} catch {
return false
}
}),
@@ -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')
}
})
})
})
@@ -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,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,
@@ -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)')
})
})
@@ -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
View File
@@ -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,
@@ -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
View File
@@ -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
View File
@@ -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.
@@ -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()
@@ -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)
@@ -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")
}
}