Next (#845)
This commit is contained in:
commit
9c5b68f47c
|
|
@ -17,3 +17,26 @@ repos:
|
||||||
- id: conventional-pre-commit
|
- id: conventional-pre-commit
|
||||||
stages: [commit-msg]
|
stages: [commit-msg]
|
||||||
args: [] # optional: list of Conventional Commits types to allow e.g. [feat, fix, ci, chore, test]
|
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
|
||||||
|
|
|
||||||
6
Makefile
6
Makefile
|
|
@ -11,6 +11,12 @@ service-prep:
|
||||||
service-unittests:
|
service-unittests:
|
||||||
$(MAKE) -wC service unittests
|
$(MAKE) -wC service unittests
|
||||||
|
|
||||||
|
service-codestyle:
|
||||||
|
$(MAKE) -wC service codestyle
|
||||||
|
|
||||||
|
frontend-codestyle:
|
||||||
|
$(MAKE) -wC frontend codestyle
|
||||||
|
|
||||||
it:
|
it:
|
||||||
$(MAKE) -wC integration-tests
|
$(MAKE) -wC integration-tests
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner {
|
||||||
|
|
||||||
console.log(" OliveTin starting local process...")
|
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
|
let logStdout = false
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -10,6 +10,7 @@ package entities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
|
@ -26,6 +27,7 @@ type variableBase struct {
|
||||||
|
|
||||||
CurrentEntity interface{}
|
CurrentEntity interface{}
|
||||||
Arguments map[string]string
|
Arguments map[string]string
|
||||||
|
Env map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type installationInfo struct {
|
type installationInfo struct {
|
||||||
|
|
@ -41,12 +43,21 @@ var (
|
||||||
func init() {
|
func init() {
|
||||||
rwmutex.Lock()
|
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{
|
contents = &variableBase{
|
||||||
OliveTin: installationInfo{
|
OliveTin: installationInfo{
|
||||||
Build: installationinfo.Build,
|
Build: installationinfo.Build,
|
||||||
Runtime: installationinfo.Runtime,
|
Runtime: installationinfo.Runtime,
|
||||||
},
|
},
|
||||||
Entities: make(entitiesByClass, 0),
|
Entities: make(entitiesByClass, 0),
|
||||||
|
Env: envMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
rwmutex.Unlock()
|
rwmutex.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) s
|
||||||
OliveTin: GetAll().OliveTin,
|
OliveTin: GetAll().OliveTin,
|
||||||
Arguments: args,
|
Arguments: args,
|
||||||
CurrentEntity: entdata,
|
CurrentEntity: entdata,
|
||||||
|
Env: GetAll().Env,
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package oncalendarfile
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/OliveTin/OliveTin/internal/auth"
|
"github.com/OliveTin/OliveTin/internal/auth"
|
||||||
|
|
@ -13,11 +14,27 @@ import (
|
||||||
"gopkg.in/yaml.v3"
|
"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) {
|
func Schedule(cfg *config.Config, ex *executor.Executor) {
|
||||||
for _, action := range cfg.Actions {
|
for _, action := range cfg.Actions {
|
||||||
|
captured := action
|
||||||
|
|
||||||
if action.ExecOnCalendarFile != "" {
|
if action.ExecOnCalendarFile != "" {
|
||||||
x := func(filename string) {
|
x := func(filename string) {
|
||||||
parseCalendarFile(action, cfg, ex, filename)
|
parseCalendarFile(captured, cfg, ex, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
go filehelper.WatchFileWrite(action.ExecOnCalendarFile, x)
|
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) {
|
func parseCalendarFile(action *config.Action, cfg *config.Config, ex *executor.Executor, filename string) {
|
||||||
|
clearExistingTimers(action)
|
||||||
|
|
||||||
filehelper.Touch(action.ExecOnCalendarFile, "calendar file")
|
filehelper.Touch(action.ExecOnCalendarFile, "calendar file")
|
||||||
|
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
|
|
@ -61,12 +101,44 @@ func scheduleCalendarActions(entries []string, action *config.Action, cfg *confi
|
||||||
continue
|
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)
|
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) {
|
func sleepUntil(ctx context.Context, instant time.Time, action *config.Action, cfg *config.Config, ex *executor.Executor) {
|
||||||
if time.Now().After(instant) {
|
if time.Now().After(instant) {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
|
|
@ -82,15 +154,21 @@ func sleepUntil(ctx context.Context, instant time.Time, action *config.Action, c
|
||||||
"actionTitle": action.Title,
|
"actionTitle": action.Title,
|
||||||
}).Infof("Scheduling action on calendar")
|
}).Infof("Scheduling action on calendar")
|
||||||
|
|
||||||
|
childCtx, cancel := context.WithCancel(ctx)
|
||||||
timer := time.NewTimer(time.Until(instant))
|
timer := time.NewTimer(time.Until(instant))
|
||||||
|
|
||||||
|
registerTimer(action, instant, timer, cancel)
|
||||||
|
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
|
unregisterTimer(action, instant)
|
||||||
exec(instant, action, cfg, ex)
|
exec(instant, action, cfg, ex)
|
||||||
return
|
return
|
||||||
case <-ctx.Done():
|
case <-childCtx.Done():
|
||||||
|
unregisterTimer(action, instant)
|
||||||
log.Infof("Cancelled scheduled action")
|
log.Infof("Cancelled scheduled action")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue