nit UI + more tests

This commit is contained in:
Ruben Fiszel
2026-02-11 06:27:21 +00:00
parent 31bfccc745
commit 5e9b4cfa99
19 changed files with 436 additions and 28 deletions

View File

@@ -0,0 +1,411 @@
use sqlx::{Pool, Postgres};
mod common;
use common::*;
/// Test that workspace error handler can be set and removed via database operations
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn test_error_handler_settings(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let _server = ApiServer::start(db.clone()).await?;
// Initially error_handler should be NULL
let initial = sqlx::query_scalar!(
r#"SELECT error_handler->>'path' FROM workspace_settings WHERE workspace_id = 'test-workspace'"#
)
.fetch_one(&db)
.await?;
assert!(initial.is_none());
// Set error handler with all options
sqlx::query!(
r#"
UPDATE workspace_settings
SET error_handler = '{"path": "script/f/test/error_handler", "extra_args": {"notify": true}, "muted_on_cancel": true, "muted_on_user_path": false}'::jsonb
WHERE workspace_id = 'test-workspace'
"#
)
.execute(&db)
.await?;
let after_set = sqlx::query_scalar!(
r#"SELECT error_handler->>'path' FROM workspace_settings WHERE workspace_id = 'test-workspace'"#
)
.fetch_one(&db)
.await?;
assert_eq!(
after_set,
Some("script/f/test/error_handler".to_string())
);
// Verify extra_args
let extra_args = sqlx::query_scalar!(
r#"SELECT error_handler->'extra_args' FROM workspace_settings WHERE workspace_id = 'test-workspace'"#
)
.fetch_one(&db)
.await?;
assert!(extra_args.is_some());
// Verify muted_on_cancel
let muted_on_cancel = sqlx::query_scalar!(
r#"SELECT (error_handler->>'muted_on_cancel')::boolean FROM workspace_settings WHERE workspace_id = 'test-workspace'"#
)
.fetch_one(&db)
.await?;
assert_eq!(muted_on_cancel, Some(true));
// Verify muted_on_user_path
let muted_on_user_path = sqlx::query_scalar!(
r#"SELECT (error_handler->>'muted_on_user_path')::boolean FROM workspace_settings WHERE workspace_id = 'test-workspace'"#
)
.fetch_one(&db)
.await?;
assert_eq!(muted_on_user_path, Some(false));
// Remove error handler
sqlx::query!(
r#"
UPDATE workspace_settings
SET error_handler = NULL
WHERE workspace_id = 'test-workspace'
"#
)
.execute(&db)
.await?;
let after_remove = sqlx::query_scalar!(
r#"SELECT error_handler->>'path' FROM workspace_settings WHERE workspace_id = 'test-workspace'"#
)
.fetch_one(&db)
.await?;
assert!(after_remove.is_none());
Ok(())
}
/// Test that a failed job triggers the workspace error handler
#[cfg(all(feature = "deno_core", feature = "enterprise", feature = "private"))]
#[sqlx::test(fixtures("base"))]
async fn test_error_handler_triggered_on_failure(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::jobs::JobPayload;
use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings};
use windmill_common::scripts::{ScriptHash, ScriptLang};
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
// Create the error handler script
let error_handler_code = r#"
export async function main(path: string, email: string, job_id: string, is_flow: boolean, workspace_id: string, error: any) {
console.log("Error handler called for job:", job_id);
return { handled: true, original_path: path };
}
"#;
sqlx::query!(
r#"
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
VALUES ('test-workspace', 1111111111, 'f/test/error_handler', $1, 'deno', 'script', 'test-user', '{}', 'Error handler script', 'Handles failed job completions', '')
"#,
error_handler_code
)
.execute(&db)
.await?;
// Create a script that will fail
let failing_script_code = "export function main() { throw new Error('intentional failure'); }";
let failing_script_hash: i64 = 2222222222;
sqlx::query!(
r#"
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
VALUES ('test-workspace', $1, 'f/test/failing_script', $2, 'deno', 'script', 'test-user', '{}', 'Failing test script', 'A script that always fails', '')
"#,
failing_script_hash,
failing_script_code
)
.execute(&db)
.await?;
// Set up the error handler in workspace_settings
sqlx::query!(
r#"
UPDATE workspace_settings
SET error_handler = '{"path": "script/f/test/error_handler"}'::jsonb
WHERE workspace_id = 'test-workspace'
"#
)
.execute(&db)
.await?;
// Create the error_handler group
sqlx::query!(
r#"
INSERT INTO group_ (workspace_id, name, summary, extra_perms)
VALUES ('test-workspace', 'error_handler', 'The group the error handler acts on behalf of', '{"u/test-user": true}')
ON CONFLICT DO NOTHING
"#
)
.execute(&db)
.await?;
// Run the failing script
let completed_job = RunJob::from(JobPayload::ScriptHash {
hash: ScriptHash(failing_script_hash),
path: "f/test/failing_script".to_string(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
language: ScriptLang::Deno,
priority: None,
apply_preprocessor: false,
concurrency_settings: ConcurrencySettings::default(),
debouncing_settings: DebouncingSettings::default(),
})
.run_until_complete(&db, false, server.addr.port())
.await;
// Verify the job actually failed
assert!(!completed_job.success, "Job should have failed");
let main_job_id = completed_job.id;
// Wait for the error handler job to be created
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Verify the error handler job was created
let error_handler_job = sqlx::query!(
r#"
SELECT
id,
runnable_path,
permissioned_as_email,
parent_job
FROM v2_job
WHERE workspace_id = 'test-workspace'
AND permissioned_as_email = 'error_handler@windmill.dev'
ORDER BY created_at DESC
LIMIT 1
"#
)
.fetch_optional(&db)
.await?;
assert!(
error_handler_job.is_some(),
"Error handler job should have been created"
);
let handler_job = error_handler_job.unwrap();
assert_eq!(
handler_job.runnable_path.as_deref(),
Some("f/test/error_handler"),
"Error handler should run the configured script"
);
assert_eq!(
handler_job.permissioned_as_email.as_str(),
"error_handler@windmill.dev",
"Error handler should run as error_handler user"
);
assert_eq!(
handler_job.parent_job,
Some(main_job_id),
"Error handler should have the failed job as parent"
);
Ok(())
}
/// Test that error handler is NOT triggered when ws_error_handler_muted is set on the script
#[cfg(all(feature = "deno_core", feature = "enterprise", feature = "private"))]
#[sqlx::test(fixtures("base"))]
async fn test_error_handler_muted_on_script(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::jobs::JobPayload;
use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings};
use windmill_common::scripts::{ScriptHash, ScriptLang};
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
// Create the error handler script
sqlx::query!(
r#"
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
VALUES ('test-workspace', 3333333333, 'f/test/error_handler', 'export function main() { return "handled"; }', 'deno', 'script', 'test-user', '{}', '', '', '')
"#,
)
.execute(&db)
.await?;
// Create a failing script with ws_error_handler_muted = true
let failing_script_hash: i64 = 4444444444;
sqlx::query!(
r#"
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, ws_error_handler_muted)
VALUES ('test-workspace', $1, 'f/test/muted_failing_script', 'export function main() { throw new Error("fail"); }', 'deno', 'script', 'test-user', '{}', '', '', '', true)
"#,
failing_script_hash,
)
.execute(&db)
.await?;
// Set up the error handler
sqlx::query!(
r#"
UPDATE workspace_settings
SET error_handler = '{"path": "script/f/test/error_handler"}'::jsonb
WHERE workspace_id = 'test-workspace'
"#
)
.execute(&db)
.await?;
sqlx::query!(
r#"
INSERT INTO group_ (workspace_id, name, summary, extra_perms)
VALUES ('test-workspace', 'error_handler', 'Error handler group', '{"u/test-user": true}')
ON CONFLICT DO NOTHING
"#
)
.execute(&db)
.await?;
// Run the muted failing script
let completed_job = RunJob::from(JobPayload::ScriptHash {
hash: ScriptHash(failing_script_hash),
path: "f/test/muted_failing_script".to_string(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
language: ScriptLang::Deno,
priority: None,
apply_preprocessor: false,
concurrency_settings: ConcurrencySettings::default(),
debouncing_settings: DebouncingSettings::default(),
})
.run_until_complete(&db, false, server.addr.port())
.await;
assert!(!completed_job.success, "Job should have failed");
// Wait and check that NO error handler job was created
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
let error_handler_job = sqlx::query_scalar!(
r#"
SELECT id
FROM v2_job
WHERE workspace_id = 'test-workspace'
AND permissioned_as_email = 'error_handler@windmill.dev'
"#
)
.fetch_optional(&db)
.await?;
assert!(
error_handler_job.is_none(),
"Error handler should NOT have been triggered for a muted script"
);
Ok(())
}
/// Test that error handler is NOT triggered on successful job completion
#[cfg(all(feature = "deno_core", feature = "enterprise", feature = "private"))]
#[sqlx::test(fixtures("base"))]
async fn test_error_handler_not_triggered_on_success(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::jobs::JobPayload;
use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings};
use windmill_common::scripts::{ScriptHash, ScriptLang};
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
// Create the error handler script
sqlx::query!(
r#"
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
VALUES ('test-workspace', 5555555555, 'f/test/error_handler', 'export function main() { return "handled"; }', 'deno', 'script', 'test-user', '{}', '', '', '')
"#,
)
.execute(&db)
.await?;
// Create a successful script
let success_script_hash: i64 = 6666666666;
sqlx::query!(
r#"
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
VALUES ('test-workspace', $1, 'f/test/success_script', 'export function main() { return "ok"; }', 'deno', 'script', 'test-user', '{}', '', '', '')
"#,
success_script_hash,
)
.execute(&db)
.await?;
// Set up the error handler
sqlx::query!(
r#"
UPDATE workspace_settings
SET error_handler = '{"path": "script/f/test/error_handler"}'::jsonb
WHERE workspace_id = 'test-workspace'
"#
)
.execute(&db)
.await?;
sqlx::query!(
r#"
INSERT INTO group_ (workspace_id, name, summary, extra_perms)
VALUES ('test-workspace', 'error_handler', 'Error handler group', '{"u/test-user": true}')
ON CONFLICT DO NOTHING
"#
)
.execute(&db)
.await?;
// Run the successful script
let completed_job = RunJob::from(JobPayload::ScriptHash {
hash: ScriptHash(success_script_hash),
path: "f/test/success_script".to_string(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
language: ScriptLang::Deno,
priority: None,
apply_preprocessor: false,
concurrency_settings: ConcurrencySettings::default(),
debouncing_settings: DebouncingSettings::default(),
})
.run_until_complete(&db, false, server.addr.port())
.await;
assert!(completed_job.success, "Job should have succeeded");
// Wait and check that NO error handler job was created
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
let error_handler_job = sqlx::query_scalar!(
r#"
SELECT id
FROM v2_job
WHERE workspace_id = 'test-workspace'
AND permissioned_as_email = 'error_handler@windmill.dev'
"#
)
.fetch_optional(&db)
.await?;
assert!(
error_handler_job.is_none(),
"Error handler should NOT have been triggered for a successful job"
);
Ok(())
}

View File

@@ -51,7 +51,7 @@
{/snippet}
<TextInput
inputProps={{ type: 'number' }}
bind:value={() => min?.toString(), (v) => (min = v ? parseInt(v) : undefined)}
bind:value={() => min?.toString(), (v) => (min = v !== '' && v != null ? parseInt(v) : undefined)}
/>
</Label>
@@ -64,7 +64,7 @@
{/snippet}
<TextInput
inputProps={{ type: 'number' }}
bind:value={() => max?.toString(), (v) => (max = v ? parseInt(v) : undefined)}
bind:value={() => max?.toString(), (v) => (max = v !== '' && v != null ? parseInt(v) : undefined)}
/>
</Label>

View File

@@ -157,7 +157,7 @@
>
<div class="leading-6 font-semibold text-sm w-full flex justify-between">
<div>Migrate to CSS editor</div><CloseButton
on:close={() => (migrationModalOpen = false)}
onClick={() => (migrationModalOpen = false)}
/>
</div>

View File

@@ -164,7 +164,7 @@
<CloseButton
noBg
small
on:close={() => {
onClick={() => {
items = items.filter((_, i) => i !== index)
}}
/>

View File

@@ -205,7 +205,7 @@
bind:value={items[index].value}
/>
<div class="absolute right-8">
<CloseButton noBg small on:close={() => deleteSubgrid(index)} />
<CloseButton noBg small onClick={() => deleteSubgrid(index)} />
</div>
<div class="flex flex-col justify-center gap-2">

View File

@@ -104,7 +104,7 @@
/>
{#if deletable}
<div class="flex flex-row-reverse -mt-4">
<CloseButton noBg on:close={() => dispatch('delete', k)} />
<CloseButton noBg onClick={() => dispatch('delete', k)} />
</div>
{/if}
{/if}

View File

@@ -195,7 +195,7 @@
<div class="flex flex-row items-center gap-1">
<CloseButton
small
on:close={() => deleteComponent(component.id, item.originalIndex)}
onClick={() => deleteComponent(component.id, item.originalIndex)}
/>
</div>
</div>

View File

@@ -29,7 +29,7 @@
<div
class="flex justify-between {right ? '' : 'flex-row-reverse'} items-center gap-1 px-3 py-2"
>
<CloseButton on:close={() => secondaryMenu?.close()} />
<CloseButton onClick={() => secondaryMenu?.close()} />
{#if $secondaryMenu?.props?.type === 'style'}
<div class="flex flex-row items-center gap-1">
<div class="text-xs font-bold"> Style Panel</div>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import Button from './button/Button.svelte'
import { X } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
@@ -10,17 +9,15 @@
Icon?: any | undefined
class?: string
id?: string | undefined
onClick?: () => void | undefined | any
onClick?: (e: Event) => void
}
let { noBg = false, small = false, Icon, class: className, id, onClick }: Props = $props()
const dispatch = createEventDispatcher()
</script>
<Button
on:click={() => (dispatch('close'), onClick?.())}
on:pointerdown={(e) => e.stopPropagation()}
{onClick}
onPointerdown={(e) => e.stopPropagation()}
{id}
startIcon={{ icon: Icon ?? X }}
iconOnly

View File

@@ -91,7 +91,7 @@
dropdownBtnClasses?: string
dropdownItems?: MenuItem[] | (() => MenuItem[]) | undefined
hideDropdown?: boolean
onClick?: (e?: Event) => void
onClick?: (e: Event) => void
children?: import('svelte').Snippet
tooltip?: import('svelte').Snippet
[key: string]: any

View File

@@ -61,7 +61,7 @@
}
}}
>
<CloseButton on:close Icon={CloseIcon} id="{id}-close-btn" />
<CloseButton onClick={() => dispatch('close')} Icon={CloseIcon} id="{id}-close-btn" />
</div>
<span class="font-semibold text-emphasis truncate text-lg max-w-sm"
>{title ?? ''}

View File

@@ -90,7 +90,7 @@
{style}
>
{#if kind == 'X'}
<div class="absolute top-4 right-4"><CloseButton on:close={() => (open = false)} /></div
<div class="absolute top-4 right-4"><CloseButton onClick={() => (open = false)} /></div
>
{/if}
<div class="flex">

View File

@@ -142,7 +142,7 @@
onblur={handleBlur}
/>
<span class="text-secondary text-2xs inline-grid [&>*]:col-start-1 [&>*]:row-start-1"
><span class="invisible">days</span><span>{day && day > 1 ? 'days' : 'day'}</span></span
><span class="invisible">days</span><span>{day !== 1 ? 'days' : 'day'}</span></span
>
</div>
<div class="flex items-baseline">
@@ -166,7 +166,7 @@
onblur={handleBlur}
/>
<span class="text-secondary text-2xs inline-grid [&>*]:col-start-1 [&>*]:row-start-1"
><span class="invisible">hrs</span><span>{hour && hour > 1 ? 'hrs' : 'hr'}</span></span
><span class="invisible">hrs</span><span>{hour !== 1 ? 'hrs' : 'hr'}</span></span
>
</div>
<div class="flex items-baseline">
@@ -190,7 +190,7 @@
onblur={handleBlur}
/>
<span class="text-secondary text-2xs inline-grid [&>*]:col-start-1 [&>*]:row-start-1"
><span class="invisible">mins</span><span>{min && min > 1 ? 'mins' : 'min'}</span></span
><span class="invisible">mins</span><span>{min !== 1 ? 'mins' : 'min'}</span></span
>
</div>
<div class="flex items-baseline">
@@ -214,7 +214,7 @@
onblur={handleBlur}
/>
<span class="text-secondary text-2xs inline-grid [&>*]:col-start-1 [&>*]:row-start-1"
><span class="invisible">secs</span><span>{sec && sec > 1 ? 'secs' : 'sec'}</span></span
><span class="invisible">secs</span><span>{sec !== 1 ? 'secs' : 'sec'}</span></span
>
</div>
</div>
@@ -223,7 +223,7 @@
class="bg-transparent text-secondary hover:text-primary"
noBg
small
on:close={() => {
onClick={() => {
seconds = defaultValue
}}
/>

View File

@@ -56,7 +56,7 @@
<CloseButton
class="text-hint bg-transparent border-none"
small
on:close={(e) => (onRemove(item), e.stopPropagation())}
onClick={(e) => { e.stopPropagation(); onRemove(item) }}
/>
{/if}
</li>

View File

@@ -158,7 +158,7 @@
noBg
class="ml-2 remove-all bg-transparent text-hint"
small
on:close={(e) => (clearValue(), e.stopPropagation())}
onClick={(e) => { e.stopPropagation(); clearValue() }}
/>
{/if}
<SelectDropdown

View File

@@ -147,7 +147,7 @@
class="bg-transparent text-secondary hover:text-primary"
noBg
small
on:close={clearValue}
onClick={clearValue}
/>
</div>
{:else if RightIcon}

View File

@@ -280,7 +280,7 @@
{/if}
</Cell>
<Cell class="w-12">
<CloseButton small on:close={() => removeDataTable(dataTableIndex)} />
<CloseButton small onClick={() => removeDataTable(dataTableIndex)} />
</Cell>
</Row>
{/each}

View File

@@ -378,7 +378,7 @@
</div>
</Cell>
<Cell class="w-12">
<CloseButton small on:close={() => removeDucklake(ducklakeIndex)} />
<CloseButton small onClick={() => removeDucklake(ducklakeIndex)} />
</Cell>
</Row>
{/each}

View File

@@ -227,7 +227,7 @@
{#if tableRow[0] !== null}
<CloseButton
small
on:close={() => {
onClick={() => {
if (s3ResourceSettings.secondaryStorage) {
s3ResourceSettings.secondaryStorage.splice(idx - 1, 1)
s3ResourceSettings.secondaryStorage = [...s3ResourceSettings.secondaryStorage]