This commit is contained in:
James Read 2026-01-23 09:27:51 +00:00 committed by GitHub
commit 9c5b68f47c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 256 additions and 5 deletions

View File

@ -17,3 +17,26 @@ repos:
- id: conventional-pre-commit
stages: [commit-msg]
args: [] # optional: list of Conventional Commits types to allow e.g. [feat, fix, ci, chore, test]
- repo: local
hooks:
- id: service-codestyle
name: service-codestyle
entry: make service-codestyle
language: system
pass_filenames: false
always_run: true
- id: frontend-codestyle
name: frontend-codestyle
entry: make frontend-codestyle
language: system
pass_filenames: false
always_run: true
- id: it
name: it
entry: make service-codestyle frontend-codestyle
language: system
pass_filenames: false
always_run: true

View File

@ -11,6 +11,12 @@ service-prep:
service-unittests:
$(MAKE) -wC service unittests
service-codestyle:
$(MAKE) -wC service codestyle
frontend-codestyle:
$(MAKE) -wC frontend codestyle
it:
$(MAKE) -wC integration-tests

View File

@ -76,4 +76,4 @@ function onExecutionChanged (evt) {
// Clear rate limit if not set
rateLimits[logEntry.bindingId] = 0
}
}
}

View File

@ -38,7 +38,9 @@ class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner {
console.log(" OliveTin starting local process...")
this.ot = spawn('./../service/OliveTin', ['-configdir', 'tests/' + cfg + '/'])
this.ot = spawn('./../service/OliveTin', ['-configdir', 'tests/' + cfg + '/'], {
env: process.env
})
let logStdout = false

View File

@ -0,0 +1,14 @@
#
# Integration Test Config: envTemplateIcon
# Tests that .Env template variable works in icon field
#
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
actions:
- title: Test Action with Env Icon
shell: echo "test"
icon: "{{ .Env.ADGUARD_ICON }}"

View File

@ -0,0 +1,116 @@
import { describe, it, before, after } from 'mocha'
import { expect } from 'chai'
import { By, Condition } from 'selenium-webdriver'
import * as process from 'node:process'
import {
getRootAndWait,
takeScreenshotOnFailure,
} from '../../lib/elements.js'
describe('config: envTemplateIcon', function () {
before(async function () {
// Set the environment variable before starting the runner
process.env.ADGUARD_ICON = 'test.png'
await runner.start('envTemplateIcon')
})
after(async () => {
await runner.stop()
// Clean up the environment variable
delete process.env.ADGUARD_ICON
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver);
});
it('Action icon is set from .Env template variable', async function () {
await getRootAndWait()
// Get the dashboard data which contains the actions
// executeScript automatically waits for Promises returned from the script
const dashboardResponse = await webdriver.executeScript(`
return window.client.getDashboard({ title: 'Actions' })
`)
expect(dashboardResponse).to.not.be.null
expect(dashboardResponse).to.have.own.property('dashboard')
expect(dashboardResponse.dashboard).to.have.own.property('contents')
expect(dashboardResponse.dashboard.contents).to.be.an('array')
expect(dashboardResponse.dashboard.contents.length).to.be.greaterThan(0)
// Actions are nested in dashboard contents - find the action with the expected title
// The structure is: dashboard.contents[] -> component.contents[] -> action
let testAction = null
for (const component of dashboardResponse.dashboard.contents) {
if (component.contents && Array.isArray(component.contents)) {
for (const subcomponent of component.contents) {
if (subcomponent.title === 'Test Action with Env Icon') {
testAction = subcomponent
break
}
}
}
// Also check if the component itself is the action
if (component.title === 'Test Action with Env Icon') {
testAction = component
break
}
if (testAction) break
}
expect(testAction).to.not.be.null
expect(testAction).to.have.own.property('icon')
expect(testAction.icon).to.equal('test.png')
})
it('Action button displays icon from .Env template variable', async function () {
await getRootAndWait()
// Wait for the action button to be rendered
await webdriver.wait(new Condition('wait for action button', async () => {
const btns = await webdriver.findElements(By.css('[title="Test Action with Env Icon"]'))
return btns.length === 1
}), 10000)
// Verify the button exists
const buttons = await webdriver.findElements(By.css('[title="Test Action with Env Icon"]'))
expect(buttons).to.have.length(1)
// The icon should be rendered in the button - we can check via the GetDashboard API response
// which is the most reliable way to verify the template parsing worked
// executeScript automatically waits for Promises returned from the script
const dashboardResponse = await webdriver.executeScript(`
return window.client.getDashboard({ title: 'Actions' })
`)
expect(dashboardResponse).to.not.be.null
expect(dashboardResponse).to.have.own.property('dashboard')
// Find the action with the expected title
// The structure is: dashboard.contents[] -> component.contents[] -> action
let testAction = null
for (const component of dashboardResponse.dashboard.contents) {
if (component.contents && Array.isArray(component.contents)) {
for (const subcomponent of component.contents) {
if (subcomponent.title === 'Test Action with Env Icon') {
testAction = subcomponent
break
}
}
}
// Also check if the component itself is the action
if (component.title === 'Test Action with Env Icon') {
testAction = component
break
}
if (testAction) break
}
expect(testAction).to.not.be.null
expect(testAction).to.have.own.property('icon')
expect(testAction.icon).to.equal('test.png')
})
})

View File

@ -10,6 +10,7 @@ package entities
*/
import (
"os"
"strings"
"sync"
@ -26,6 +27,7 @@ type variableBase struct {
CurrentEntity interface{}
Arguments map[string]string
Env map[string]string
}
type installationInfo struct {
@ -41,12 +43,21 @@ var (
func init() {
rwmutex.Lock()
envMap := make(map[string]string)
for _, env := range os.Environ() {
parts := strings.SplitN(env, "=", 2)
if len(parts) == 2 {
envMap[parts[0]] = parts[1]
}
}
contents = &variableBase{
OliveTin: installationInfo{
Build: installationinfo.Build,
Runtime: installationinfo.Runtime,
},
Entities: make(entitiesByClass, 0),
Env: envMap,
}
rwmutex.Unlock()

View File

@ -94,6 +94,7 @@ func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) s
OliveTin: GetAll().OliveTin,
Arguments: args,
CurrentEntity: entdata,
Env: GetAll().Env,
}
var sb strings.Builder

View File

@ -3,6 +3,7 @@ package oncalendarfile
import (
"context"
"os"
"sync"
"time"
"github.com/OliveTin/OliveTin/internal/auth"
@ -13,11 +14,27 @@ import (
"gopkg.in/yaml.v3"
)
type timerEntry struct {
timer *time.Timer
cancel context.CancelFunc
}
type existingTimers struct {
timers map[time.Time]timerEntry
}
var (
scheduleMap = make(map[string]existingTimers)
scheduleMapMutex sync.RWMutex
)
func Schedule(cfg *config.Config, ex *executor.Executor) {
for _, action := range cfg.Actions {
captured := action
if action.ExecOnCalendarFile != "" {
x := func(filename string) {
parseCalendarFile(action, cfg, ex, filename)
parseCalendarFile(captured, cfg, ex, filename)
}
go filehelper.WatchFileWrite(action.ExecOnCalendarFile, x)
@ -27,7 +44,30 @@ func Schedule(cfg *config.Config, ex *executor.Executor) {
}
}
func clearExistingTimers(action *config.Action) {
scheduleMapMutex.Lock()
defer scheduleMapMutex.Unlock()
if _, exists := scheduleMap[action.ID]; exists {
for instant, entry := range scheduleMap[action.ID].timers {
log.WithFields(log.Fields{
"instant": instant,
"actionTitle": action.Title,
}).Infof("Clearing existing scheduled action from calendar")
entry.cancel()
entry.timer.Stop()
}
}
scheduleMap[action.ID] = existingTimers{
timers: make(map[time.Time]timerEntry),
}
}
func parseCalendarFile(action *config.Action, cfg *config.Config, ex *executor.Executor, filename string) {
clearExistingTimers(action)
filehelper.Touch(action.ExecOnCalendarFile, "calendar file")
log.WithFields(log.Fields{
@ -61,12 +101,44 @@ func scheduleCalendarActions(entries []string, action *config.Action, cfg *confi
continue
}
until, _ := time.Parse(time.RFC3339, instant)
until, err := time.Parse(time.RFC3339, instant)
if err != nil {
log.WithFields(log.Fields{
"instant": instant,
"actionTitle": action.Title,
}).Warnf("Invalid calendar entry, skipping: %v", err)
continue
}
go sleepUntil(ctx, until, action, cfg, ex)
}
}
func registerTimer(action *config.Action, instant time.Time, timer *time.Timer, cancel context.CancelFunc) {
scheduleMapMutex.Lock()
defer scheduleMapMutex.Unlock()
if _, exists := scheduleMap[action.ID]; !exists {
scheduleMap[action.ID] = existingTimers{
timers: make(map[time.Time]timerEntry),
}
}
scheduleMap[action.ID].timers[instant] = timerEntry{
timer: timer,
cancel: cancel,
}
}
func unregisterTimer(action *config.Action, instant time.Time) {
scheduleMapMutex.Lock()
v := scheduleMap[action.ID]
if v.timers != nil {
delete(v.timers, instant)
}
scheduleMapMutex.Unlock()
}
func sleepUntil(ctx context.Context, instant time.Time, action *config.Action, cfg *config.Config, ex *executor.Executor) {
if time.Now().After(instant) {
log.WithFields(log.Fields{
@ -82,15 +154,21 @@ func sleepUntil(ctx context.Context, instant time.Time, action *config.Action, c
"actionTitle": action.Title,
}).Infof("Scheduling action on calendar")
childCtx, cancel := context.WithCancel(ctx)
timer := time.NewTimer(time.Until(instant))
registerTimer(action, instant, timer, cancel)
defer timer.Stop()
defer cancel()
select {
case <-timer.C:
unregisterTimer(action, instant)
exec(instant, action, cfg, ex)
return
case <-ctx.Done():
case <-childCtx.Done():
unregisterTimer(action, instant)
log.Infof("Cancelled scheduled action")
return
}