feat: git sync improvements (#6182)

* init checkpoint

* ui second pass...

* round 1 backend + saving settings + detecting changes...

* checkpoint

* fix openapi

* saving + correct wmill.yaml diff

* cli refactor

* cli and tests refactor done

* cli multi workspace support

* cli support skip core types to align with ui

* new test framework

* sqlx

* openapi spec

* frontend

* sync + settings changes

* some fixes

* some fixes

* security: Remove hardcoded EE license key, use environment variable only

- Remove hardcoded license key from containerized test backend
- Environment variable EE_LICENSE_KEY now required for EE features
- License key no longer stored in database during tests

* sqlx

* tests

* fixing tests

* fix tests

* checkpoint

* checkpoint

* cli build

* frontend - cli exchange

* settings match

* ee repo ref

* npm check

* openapi

* tests

* checkpoint

* cli + tests

* reset to preview on changes

* merge issue ee

* cleanup

* hubscript

* simplifications

* ee repo ref

* cli fixes

* fix sync and add tests

* extra test

* git sync settings / key change aware

* ee-repo ref

* ee-repo ref

* ee repo ref

* ee ref

* review 1

* ee ref

* Update frontend/src/lib/components/PullGitRepoPopover.svelte

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* ee ref

* remove extra includes from ui

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Alexander Petric
2025-07-15 12:25:31 -04:00
committed by GitHub
parent 574f14b349
commit 27bf4e34d8
41 changed files with 7892 additions and 924 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
version: "3.7"
x-logging: &default-logging
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
compress: "true"
services:
test_db:
image: postgres:16
environment:
POSTGRES_PASSWORD: testpass123
POSTGRES_DB: windmill_test
POSTGRES_USER: postgres
ports:
- "5433:5432" # Use different port to avoid conflicts
volumes:
- test_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d windmill_test"]
interval: 10s
timeout: 5s
retries: 5
logging: *default-logging
test_windmill_server:
image: windmill-test:latest
environment:
- DATABASE_URL=postgres://postgres:testpass123@test_db/windmill_test?sslmode=disable
- MODE=server
- LICENSE_KEY=${EE_LICENSE_KEY}
- RUST_LOG=info
- DISABLE_TELEMETRY=true
- METRICS_ENABLED=false
ports:
- "8001:8000" # Use different port to avoid conflicts
depends_on:
test_db:
condition: service_healthy
volumes:
- test_worker_logs:/tmp/windmill/logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/version"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
logging: *default-logging
test_windmill_worker:
image: windmill-test:latest
environment:
- DATABASE_URL=postgres://postgres:testpass123@test_db/windmill_test?sslmode=disable
- MODE=worker
- WORKER_GROUP=default
- LICENSE_KEY=${EE_LICENSE_KEY}
- RUST_LOG=info
- DISABLE_TELEMETRY=true
- NUM_WORKERS=1
- SLEEP_QUEUE=50
depends_on:
test_db:
condition: service_healthy
test_windmill_server:
condition: service_healthy
volumes:
- test_worker_logs:/tmp/windmill/logs
logging: *default-logging
volumes:
test_db_data: null
test_worker_logs: null

View File

@@ -0,0 +1,177 @@
import { assertEquals, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { withContainerizedBackend } from "./containerized_backend.ts";
import { addWorkspace } from "../workspace.ts";
// =============================================================================
// GITSYNC-SETTINGS COMMAND FEATURES
// Tests for additional gitsync-settings command functionality
// =============================================================================
Deno.test("GitSync Settings: workspace-level wildcard settings", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "workspace_level_test",
token: backend.token
};
await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir });
// Configure backend with repository
await backend.updateGitSyncConfig({
git_sync_settings: {
repositories: [{
git_repo_resource_path: "u/test/workspace_repo",
script_path: "f/**",
group_by_folder: false,
use_individual_branch: false,
settings: {
include_path: ["f/**"],
include_type: ["script", "flow"],
exclude_path: [],
extra_include_path: []
}
}]
}
});
// Create initial wmill.yaml
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- f/**
excludes: []`);
// Pull with workspace-level flag
const result = await backend.runCLICommand([
'gitsync-settings', 'pull',
'--repository', 'u/test/workspace_repo',
'--workspace-level',
'--override'
], tempDir);
assertEquals(result.code, 0, `Workspace-level pull should succeed: ${result.stderr}`);
// Read updated config
const updatedConfig = await Deno.readTextFile(`${tempDir}/wmill.yaml`);
const backendUrl = new URL(backend.baseUrl).toString();
// Should create workspace wildcard override
assertStringIncludes(updatedConfig, `'${backendUrl}:${backend.workspace}:*':`);
assertStringIncludes(updatedConfig, "overrides:");
});
});
Deno.test("GitSync Settings: default mode writes to top-level", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "default_mode_test",
token: backend.token
};
await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir });
// Configure backend with specific settings
await backend.updateGitSyncConfig({
git_sync_settings: {
repositories: [{
git_repo_resource_path: "u/test/default_repo",
script_path: "f/**",
group_by_folder: false,
use_individual_branch: false,
settings: {
include_path: ["f/special/**"],
include_type: ["script"],
exclude_path: ["*.test.ts"],
extra_include_path: ["g/**"]
}
}]
}
});
// Create initial wmill.yaml with different settings
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- f/**
excludes: []
skipVariables: false`);
// Pull with default flag
const result = await backend.runCLICommand([
'gitsync-settings', 'pull',
'--repository', 'u/test/default_repo',
'--default'
], tempDir);
assertEquals(result.code, 0, `Default mode pull should succeed: ${result.stderr}`);
// Read updated config
const updatedConfig = await Deno.readTextFile(`${tempDir}/wmill.yaml`);
// Should update top-level settings, not create overrides
assertStringIncludes(updatedConfig, "includes:\n - f/special/**");
assertStringIncludes(updatedConfig, "excludes:\n - '*.test.ts'");
assertStringIncludes(updatedConfig, "extraIncludes:\n - g/**");
// Should NOT have overrides section
assertEquals(updatedConfig.includes("overrides:"), false, "Default mode should not create overrides");
});
});
// Removed test for non-existent repository error handling
// as it was testing non-deterministic behavior
Deno.test("GitSync Settings: pull shows correct diff output", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "diff_test",
token: backend.token
};
await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir });
// Configure backend
await backend.updateGitSyncConfig({
git_sync_settings: {
repositories: [{
git_repo_resource_path: "u/test/diff_repo",
script_path: "f/**",
group_by_folder: false,
use_individual_branch: false,
settings: {
include_path: ["f/**"],
include_type: ["script", "flow"],
exclude_path: [],
extra_include_path: []
}
}]
}
});
// Create wmill.yaml with different settings
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- f/**
excludes: []
skipVariables: true
skipResources: false`);
// Pull with diff flag
const result = await backend.runCLICommand([
'gitsync-settings', 'pull',
'--repository', 'u/test/diff_repo',
'--diff'
], tempDir);
assertEquals(result.code, 0);
// Should show differences
assertStringIncludes(result.stdout, "Changes that would be made:");
// Should show the change for skipResources (ignoring ANSI color codes)
assertStringIncludes(result.stdout, "skipResources:");
});
});

View File

@@ -0,0 +1,193 @@
import { assertEquals, assertStringIncludes, assert } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { withContainerizedBackend } from "./containerized_backend.ts";
import { addWorkspace } from "../workspace.ts";
import { parseJsonFromCLIOutput } from "./test_config_helpers.ts";
// =============================================================================
// INCLUDE FLAGS BYPASS FILTERING TESTS
// Tests that CLI include flags properly bypass path-based filtering
// =============================================================================
// Helper function to set up workspace profile
async function setupWorkspaceProfile(backend: any): Promise<void> {
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "localhost_test",
token: backend.token
};
await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir });
}
// The ContainerizedBackend already creates test data we can use:
// - admin user (admin@windmill.dev)
// - test_group (created by seedTestData())
// - workspace encryption key
// - test apps, resources, variables via seedTestData()
// No additional setup needed!
Deno.test("CLI include flags bypass restrictive path filtering", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
// Create wmill.yaml with very restrictive includes that would exclude special files
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "f/**"
excludes: []
skipVariables: true
skipResources: true
includeUsers: false
includeGroups: false
includeSettings: false
includeKey: false`);
// Test: CLI flags should override config and bypass path filtering
const result = await backend.runCLICommand([
'sync', 'pull',
'--include-users',
'--include-groups',
'--include-settings',
'--include-key',
'--dry-run',
'--json-output'
], tempDir);
assertEquals(result.code, 0, `Command failed: ${result.stderr}`);
const output = parseJsonFromCLIOutput(result.stdout);
const changePaths = output.changes.map((c: any) => c.path);
// Assert that special files are included despite restrictive path filtering
const hasUser = changePaths.some((path: string) => path.includes('admin@windmill.dev.user.yaml'));
const hasGroup = changePaths.some((path: string) => path.includes('groups/test_group.group.yaml'));
const hasSettings = changePaths.some((path: string) => path === 'settings.yaml');
const hasEncryptionKey = changePaths.some((path: string) => path === 'encryption_key.yaml');
assert(hasUser, `Admin user should be included despite restrictive includes. Found paths: ${changePaths.join(', ')}`);
assert(hasGroup, `'test_group' should be included despite restrictive includes. Found paths: ${changePaths.join(', ')}`);
assert(hasSettings, `Settings should be included despite restrictive includes. Found paths: ${changePaths.join(', ')}`);
assert(hasEncryptionKey, `Encryption key should be included despite restrictive includes. Found paths: ${changePaths.join(', ')}`);
});
});
Deno.test("CLI flags override wmill.yaml include settings", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
// Config explicitly disables includes, but CLI should override
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
excludes: []
includeUsers: false
includeGroups: false`);
// CLI flags should override config file settings
const result = await backend.runCLICommand([
'sync', 'pull',
'--include-users',
'--include-groups',
'--dry-run',
'--json-output'
], tempDir);
assertEquals(result.code, 0, `Command failed: ${result.stderr}`);
const output = parseJsonFromCLIOutput(result.stdout);
const changePaths = output.changes.map((c: any) => c.path);
const hasUser = changePaths.some((path: string) => path.includes('admin@windmill.dev.user.yaml'));
const hasGroup = changePaths.some((path: string) => path.includes('groups/test_group.group.yaml'));
assert(hasUser, `CLI --include-users should override config includeUsers: false. Found paths: ${changePaths.join(', ')}`);
assert(hasGroup, `CLI --include-groups should override config includeGroups: false. Found paths: ${changePaths.join(', ')}`);
});
});
Deno.test("Skip flags work correctly with getTypeStrFromPath and lock files", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
// Create wmill.yaml with skip flags enabled
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
excludes: []
skipScripts: true
skipFlows: false
includeUsers: true`);
const result = await backend.runCLICommand([
'sync', 'pull',
'--dry-run',
'--json-output'
], tempDir);
assertEquals(result.code, 0, `Command failed: ${result.stderr}`);
const output = parseJsonFromCLIOutput(result.stdout);
const changePaths = output.changes.map((c: any) => c.path);
// Scripts should be skipped (including lock files) - the backend doesn't create scripts by default
const hasScript = changePaths.some((path: string) =>
path.endsWith('.py') || path.endsWith('.ts') || path.endsWith('.go') || path.endsWith('.sh')
);
const hasScriptLock = changePaths.some((path: string) => path.endsWith('.script.lock'));
// Apps should be included (the backend creates test apps)
const hasApp = changePaths.some((path: string) => path.includes('test_dashboard') || path.endsWith('.app.yaml'));
// Users should still be included
const hasUser = changePaths.some((path: string) => path.includes('admin@windmill.dev.user.yaml'));
assert(!hasScript, `Standalone scripts should be skipped when skipScripts: true. Found paths: ${changePaths.join(', ')}`);
assert(!hasScriptLock, `Script lock files should be skipped when skipScripts: true. Found paths: ${changePaths.join(', ')}`);
assert(hasApp, `Apps should be included (inline scripts are part of apps). Found paths: ${changePaths.join(', ')}`);
assert(hasUser, `Users should be included when includeUsers: true. Found paths: ${changePaths.join(', ')}`);
});
});
Deno.test("Mixed include and skip flags work together", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
// Create restrictive config with mixed settings
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "f/**"
excludes: []
skipScripts: true
includeUsers: false
includeSettings: false`);
const result = await backend.runCLICommand([
'sync', 'pull',
'--skip-scripts', // Reinforce script skipping
'--include-users', // Override config to include users
'--dry-run',
'--json-output'
], tempDir);
assertEquals(result.code, 0, `Command failed: ${result.stderr}`);
const output = parseJsonFromCLIOutput(result.stdout);
const changePaths = output.changes.map((c: any) => c.path);
// Scripts should be excluded
const hasScript = changePaths.some((path: string) =>
path.endsWith('.py') || path.endsWith('.ts') || path.endsWith('.go') || path.endsWith('.sh')
);
// Users should be included (CLI override)
const hasUser = changePaths.some((path: string) => path.includes('admin@windmill.dev.user.yaml'));
// Settings should be excluded (no CLI override, restrictive path filtering)
const hasSettings = changePaths.some((path: string) => path === 'settings.yaml');
assert(!hasScript, `Scripts should be excluded due to skipScripts. Found paths: ${changePaths.join(', ')}`);
assert(hasUser, `Users should be included due to CLI --include-users override. Found paths: ${changePaths.join(', ')}`);
assert(!hasSettings, `Settings should be excluded (no CLI override + restrictive paths). Found paths: ${changePaths.join(', ')}`);
});
});

View File

@@ -0,0 +1,185 @@
/**
* Test to verify that wmill init handles workspaces with no git-sync settings correctly
* This creates a unit test that directly tests the logic without needing a backend
*/
import { assertEquals, assertStringIncludes, assert } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { DEFAULT_SYNC_OPTIONS } from "../conf.ts";
import { withContainerizedBackend } from "./containerized_backend.ts";
import { addWorkspace } from "../workspace.ts";
// Mock the workspace object
const mockWorkspace = {
remote: 'https://app.windmill.dev/',
workspaceId: 'test-workspace',
name: 'test-workspace'
};
// Simulate the createWorkspaceProfile function logic for the "no repositories" case
function createWorkspaceProfileNoRepos(workspace: any): any {
const workspaceProfile: any = {
baseUrl: workspace.remote,
workspaceId: workspace.workspaceId,
};
// Simulate the case where listRepositories returns empty array
const repositories: any[] = [];
if (repositories.length === 0) {
console.log(`No git repositories found in workspace '${workspace.workspaceId}'`);
// This is the fix: include default sync settings when no repositories exist
Object.assign(workspaceProfile, DEFAULT_SYNC_OPTIONS);
return workspaceProfile;
}
return workspaceProfile;
}
Deno.test("Init: createWorkspaceProfile includes defaults when no repositories exist", () => {
console.log('🧪 Testing init logic for workspace with no git-sync repositories...');
const workspaceProfile = createWorkspaceProfileNoRepos(mockWorkspace);
console.log('Generated workspace profile:', JSON.stringify(workspaceProfile, null, 2));
// Verify basic workspace info
assertEquals(workspaceProfile.baseUrl, 'https://app.windmill.dev/');
assertEquals(workspaceProfile.workspaceId, 'test-workspace');
// Verify default sync settings are included
assert(Array.isArray(workspaceProfile.includes), 'Should have includes array');
assertEquals(workspaceProfile.includes.length, 1, 'Should have one include pattern');
assertEquals(workspaceProfile.includes[0], 'f/**', 'Should include f/** pattern');
assert(Array.isArray(workspaceProfile.excludes), 'Should have excludes array');
assertEquals(workspaceProfile.excludes.length, 0, 'Should have empty excludes array');
assertEquals(workspaceProfile.defaultTs, 'bun', 'Should have bun as default TypeScript runtime');
console.log('✅ Workspace profile correctly includes default sync settings when no repositories exist');
});
Deno.test("Init: verify DEFAULT_SYNC_OPTIONS has expected values", () => {
console.log('🔍 Verifying DEFAULT_SYNC_OPTIONS contains expected values...');
console.log('DEFAULT_SYNC_OPTIONS:', JSON.stringify(DEFAULT_SYNC_OPTIONS, null, 2));
// Verify the default options include the expected f/** pattern
assert(Array.isArray(DEFAULT_SYNC_OPTIONS.includes), 'DEFAULT_SYNC_OPTIONS should have includes array');
assertEquals(DEFAULT_SYNC_OPTIONS.includes.length, 1, 'Should have one include pattern');
assertEquals(DEFAULT_SYNC_OPTIONS.includes[0], 'f/**', 'Should default to f/** pattern');
assert(Array.isArray(DEFAULT_SYNC_OPTIONS.excludes), 'DEFAULT_SYNC_OPTIONS should have excludes array');
assertEquals(DEFAULT_SYNC_OPTIONS.excludes.length, 0, 'Should have empty excludes array by default');
assertEquals(DEFAULT_SYNC_OPTIONS.defaultTs, 'bun', 'Should default to bun runtime');
console.log('✅ DEFAULT_SYNC_OPTIONS has expected values');
});
Deno.test("Init: --use-backend flag applies git-sync settings", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: backend.workspace,
token: backend.token
};
await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir });
// First create the git repository resource that the git-sync will reference
await backend.createAdditionalGitRepo("u/test/init_repo", "Test init repository");
// Configure backend with git-sync settings
await backend.updateGitSyncConfig({
git_sync_settings: {
repositories: [{
git_repo_resource_path: "u/test/init_repo",
script_path: "f/**",
group_by_folder: false,
use_individual_branch: false,
settings: {
include_path: ["f/backend/**"],
include_type: ["script", "flow"],
exclude_path: ["*.test.ts"],
extra_include_path: ["g/**"]
}
}]
}
});
// Run init with --use-backend flag
const result = await backend.runCLICommand([
'init',
'--use-backend',
'--repository', 'u/test/init_repo'
], tempDir);
assertEquals(result.code, 0, `Init with --use-backend should succeed: ${result.stderr}`);
// Verify wmill.yaml was created with backend settings
const wmillYaml = await Deno.readTextFile(`${tempDir}/wmill.yaml`);
// Should have backend-applied settings written to top-level (not overrides)
assertStringIncludes(wmillYaml, "f/backend/**", "Should include backend's include_path");
assertStringIncludes(wmillYaml, "*.test.ts", "Should include backend's exclude_path");
assertStringIncludes(wmillYaml, "g/**", "Should include backend's extra_include_path");
// Should NOT have overrides section since we're starting fresh
assertEquals(wmillYaml.includes("overrides:"), false, "Init should not create overrides section");
});
});
Deno.test("Init: --use-default bypasses backend settings check", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: backend.workspace,
token: backend.token
};
await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir });
// Create git repository resource
await backend.createAdditionalGitRepo("u/test/ignored_repo", "Test ignored repository");
// Configure backend with git-sync settings
await backend.updateGitSyncConfig({
git_sync_settings: {
repositories: [{
git_repo_resource_path: "u/test/ignored_repo",
script_path: "f/**",
group_by_folder: false,
use_individual_branch: false,
settings: {
include_path: ["f/should-be-ignored/**"],
include_type: ["script"],
exclude_path: [],
extra_include_path: []
}
}]
}
});
// Run init with --use-default (should ignore backend)
const result = await backend.runCLICommand([
'init',
'--use-default'
], tempDir);
assertEquals(result.code, 0, `Init with --use-default should succeed: ${result.stderr}`);
// Verify wmill.yaml was created with default settings only
const wmillYaml = await Deno.readTextFile(`${tempDir}/wmill.yaml`);
// Should have default settings, not backend settings
assertStringIncludes(wmillYaml, "includes:\n - f/**", "Should use default includes");
assertStringIncludes(wmillYaml, "defaultTs: bun", "Should use default TypeScript runtime");
// Should NOT have backend-specific settings
assertEquals(wmillYaml.includes("f/should-be-ignored/**"), false, "Should not include backend settings");
assertEquals(wmillYaml.includes("overrides:"), false, "Should not create overrides when using defaults");
});
});

View File

@@ -0,0 +1,199 @@
import { assertEquals, assert, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { withContainerizedBackend } from "./containerized_backend.ts";
import { addWorkspace } from "../workspace.ts";
import { parseJsonFromCLIOutput } from "./test_config_helpers.ts";
// =============================================================================
// MULTI-INSTANCE WORKSPACE TESTS
// Tests for handling multiple Windmill instances with same workspace IDs
// =============================================================================
// Helper function to set up workspace profile with specific name
async function setupWorkspaceProfile(backend: any, workspaceName: string): Promise<void> {
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: workspaceName,
token: backend.token
};
await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir });
}
Deno.test("Multi-Instance: gitsync-settings pull with new format", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace profile
await setupWorkspaceProfile(backend, "multi_instance_test");
// Create wmill.yaml with new format overrides for different instances
const backendUrl = new URL(backend.baseUrl).toString(); // Normalize URL
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- f/**
excludes: []
overrides:
# Current backend instance (should match)
"${backendUrl}:${backend.workspace}:u/test/test_repo":
includeTriggers: true
includeSchedules: true
skipVariables: true
# Different instance (won't match)
"https://app.windmill.dev/:${backend.workspace}:u/test/test_repo":
includeTriggers: false
includeSchedules: false
skipVariables: false`);
// Pull settings - should use the matching instance override (skipVariables: true)
const pullResult = await backend.runCLICommand([
'gitsync-settings', 'pull',
'--repository', 'u/test/test_repo',
'--diff'
], tempDir, "multi_instance_test");
assertEquals(pullResult.code, 0);
assertStringIncludes(pullResult.stdout, "Changes that would be made:");
// includeSchedules should show as a change since backend default is false
assertStringIncludes(pullResult.stdout, "includeSchedules");
});
});
Deno.test("Multi-Instance: gitsync-settings push with overrides", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace profile
await setupWorkspaceProfile(backend, "push_override_test");
// Create wmill.yaml with specific settings that differ from backend defaults
const backendUrl = new URL(backend.baseUrl).toString();
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- f/**
excludes: []
skipVariables: false
includeSchedules: false
overrides:
# Override for current backend instance - set includeSchedules: true (backend default is false)
"${backendUrl}:${backend.workspace}:u/test/test_repo":
includeSchedules: true
skipVariables: true`);
// Push settings - should show changes because includeSchedules differs from backend
const pushResult = await backend.runCLICommand([
'gitsync-settings', 'push',
'--repository', 'u/test/test_repo',
'--diff'
], tempDir, "push_override_test");
assertEquals(pushResult.code, 0);
assertStringIncludes(pushResult.stdout, "Changes that would be pushed:");
assertStringIncludes(pushResult.stdout, "includeSchedules");
});
});
Deno.test("Multi-Instance: sync with repository-specific overrides", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "my-workspace_123");
const backendUrl = new URL(backend.baseUrl).toString();
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- f/**
excludes: []
overrides:
"${backendUrl}:${backend.workspace}:u/test/test_repo":
skipApps: true`);
const result = await backend.runCLICommand([
'sync', 'pull',
'--repository', 'u/test/test_repo',
'--dry-run',
'--json-output'
], tempDir, "my-workspace_123");
assertEquals(result.code, 0);
const data = parseJsonFromCLIOutput(result.stdout);
// Test is designed to verify that the new format works correctly
// The test app should NOT appear in changes because skipApps: true
const hasTestApp = (data.changes || []).some((change: any) =>
change.path?.includes('f/test_dashboard')
);
assertEquals(hasTestApp, false, "Test app should be skipped due to skipApps override");
});
});
Deno.test("Multi-Instance: auto-detection of single repository override", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "auto_detect_test");
const backendUrl = new URL(backend.baseUrl).toString();
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- f/**
excludes: []
overrides:
# Single repository override - should be auto-detected
"${backendUrl}:${backend.workspace}:u/test/test_repo":
skipApps: true
includeSchedules: true`);
// Don't specify --repository, it should auto-detect
const result = await backend.runCLICommand([
'sync', 'pull',
'--dry-run',
'--json-output'
], tempDir, "auto_detect_test");
assertEquals(result.code, 0);
assertStringIncludes(result.stdout, "Auto-selected repository: u/test/test_repo");
const data = parseJsonFromCLIOutput(result.stdout);
// The test app should NOT appear because of auto-detected skipApps: true
const hasTestApp = (data.changes || []).some((change: any) =>
change.path?.includes('f/test_dashboard')
);
assertEquals(hasTestApp, false, "Test app should be skipped due to auto-detected override");
});
});
Deno.test("Multi-Instance: workspace wildcards with new format", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "wildcard_test");
const backendUrl = new URL(backend.baseUrl).toString();
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
excludes: []
overrides:
# Wildcard for current backend instance
"${backendUrl}:${backend.workspace}:*":
skipVariables: true
skipResources: true`);
const result = await backend.runCLICommand([
'sync', 'pull',
'--repository', 'u/test/test_repo',
'--dry-run',
'--json-output'
], tempDir, "wildcard_test");
assertEquals(result.code, 0);
const data = parseJsonFromCLIOutput(result.stdout);
// Variables should be skipped due to wildcard override
const hasTestVariable = (data.changes || []).some((change: any) =>
change.path?.includes('u/admin/test_config.variable.yaml')
);
assertEquals(hasTestVariable, false, "Variables should be skipped due to wildcard override");
});
});

View File

@@ -0,0 +1,273 @@
import { assertEquals, assert } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { getEffectiveSettings } from "../conf.ts";
import { withContainerizedBackend } from "./containerized_backend.ts";
import { addWorkspace } from "../workspace.ts";
// =============================================================================
// OVERRIDE SETTINGS BEHAVIOR TESTS
// Tests for override inheritance and file filtering behavior
// =============================================================================
Deno.test("Override Settings: override inherits non-overridden settings from base config", () => {
const config = {
includes: ["default/**"],
skipVariables: true, // Base has this as true
skipResources: true, // Base has this as true
skipApps: false, // Base has this as false
defaultTs: "bun" as const,
overrides: {
"http://localhost:8000/:test:u/user/repo": {
includes: ["override/**"],
skipApps: true // Override only changes skipApps, should inherit other skip flags
}
}
};
const effective = getEffectiveSettings(
config,
"http://localhost:8000/",
"test",
"u/user/repo"
);
// Override values should be used
assertEquals(effective.includes, ["override/**"], "Must use override includes");
assertEquals(effective.skipApps, true, "Must use override skipApps");
// Should inherit skip flags from base config
assertEquals(effective.skipVariables, true, "Must inherit skipVariables=true from base config");
assertEquals(effective.skipResources, true, "Must inherit skipResources=true from base config");
assertEquals(effective.defaultTs, "bun", "Must inherit defaultTs from base config");
});
Deno.test("Override Settings: workspace wildcards with repo-specific precedence", () => {
const config = {
includes: ["default/**"],
skipVariables: false,
overrides: {
"http://localhost:8000/:test:*": {
skipVariables: true,
includes: ["workspace/**"]
},
"http://localhost:8000/:test:u/user/specific": {
includes: ["specific/**"]
}
}
};
// Test specific repo override (should take precedence over wildcard)
const specificEffective = getEffectiveSettings(
config,
"http://localhost:8000/",
"test",
"u/user/specific"
);
assertEquals(specificEffective.includes, ["specific/**"], "Specific repo override must take precedence over wildcard");
assertEquals(specificEffective.skipVariables, true, "Workspace wildcard setting must still apply");
// Test wildcard match
const wildcardEffective = getEffectiveSettings(
config,
"http://localhost:8000/",
"test",
"u/user/other"
);
assertEquals(wildcardEffective.includes, ["workspace/**"], "Wildcard must match repos without specific overrides");
assertEquals(wildcardEffective.skipVariables, true, "Workspace wildcard setting must apply");
});
// =============================================================================
// INTEGRATION TESTS - File Filtering Behavior
// =============================================================================
Deno.test("Integration: sync pull with skipVariables override excludes variable files", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "skip_variables_test",
token: backend.token
};
await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir });
// Create wmill.yaml with override that skips variables
const backendUrl = new URL(backend.baseUrl).toString();
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
skipVariables: false
overrides:
"${backendUrl}:${backend.workspace}:u/test/test_repo":
skipVariables: true`);
// Verify backend has test variable before pull
const backendVariables = await backend.listAllVariables();
const hasTestVariable = backendVariables.some(v => v.path === 'u/admin/test_config');
assert(hasTestVariable, "Backend should have test variable before pull");
// Run sync pull (NOT dry-run) to actually write files
const result = await backend.runCLICommand([
'sync', 'pull',
'--repository', 'u/test/test_repo',
'--yes'
], tempDir);
assertEquals(result.code, 0, `Sync pull should succeed: ${result.stderr}`);
// Verify variable files were NOT written to filesystem due to skipVariables: true
const filesWritten = [];
for await (const entry of Deno.readDir(tempDir)) {
if (entry.isFile && entry.name.endsWith('.yaml')) {
filesWritten.push(entry.name);
}
}
const hasVariableFile = filesWritten.some(file => file.includes('.variable.yaml'));
assertEquals(hasVariableFile, false, "Variable files should NOT be written due to skipVariables override");
// Verify other files WERE written (since skipVariables only affects variables)
// Check what files were actually written
console.log("Files written:", filesWritten);
// Should have some files written (just not variable files)
assert(filesWritten.length > 0, `Some files should be written when skipVariables is true. Got: ${filesWritten.join(', ')}`);
// Should not have only wmill.yaml file
const nonWmillFiles = filesWritten.filter(f => !f.includes('wmill.yaml'));
assert(nonWmillFiles.length > 0, `Non-wmill.yaml files should be written when skipVariables is true. Got: ${nonWmillFiles.join(', ')}`);
});
});
Deno.test("Integration: sync push with skipVariables override excludes variable files", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "push_skip_test",
token: backend.token
};
await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir });
// Create wmill.yaml with override that skips variables
const backendUrl = new URL(backend.baseUrl).toString();
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
skipVariables: false
overrides:
"${backendUrl}:${backend.workspace}:u/test/test_repo":
skipVariables: true`);
// Create local test files including variables and scripts
const timestamp = Date.now();
// Create variable file
await Deno.mkdir(`${tempDir}/u/admin`, { recursive: true });
await Deno.writeTextFile(`${tempDir}/u/admin/test_push_var_${timestamp}.variable.yaml`,
`value: test_value_${timestamp}
description: Test variable for push override test
is_secret: false`);
// Create script file
await Deno.mkdir(`${tempDir}/f/test`, { recursive: true });
await Deno.writeTextFile(`${tempDir}/f/test/push_script_${timestamp}.ts`,
`export async function main() {
return "Test script ${timestamp}";
}`);
await Deno.writeTextFile(`${tempDir}/f/test/push_script_${timestamp}.script.yaml`,
`summary: Test Push Script ${timestamp}
description: Script for testing push with override`);
// Get backend state before push
const beforeVariables = await backend.listAllVariables();
const beforeScripts = await backend.listAllScripts();
const variableExistsBefore = beforeVariables.some(v => v.path === `u/admin/test_push_var_${timestamp}`);
const scriptExistsBefore = beforeScripts.some(s => s.path === `f/test/push_script_${timestamp}`);
assertEquals(variableExistsBefore, false, "Variable should not exist before push");
assertEquals(scriptExistsBefore, false, "Script should not exist before push");
// Run sync push (NOT dry-run) to actually push files
const result = await backend.runCLICommand([
'sync', 'push',
'--repository', 'u/test/test_repo',
'--yes'
], tempDir);
assertEquals(result.code, 0, `Sync push should succeed: ${result.stderr}`);
// Verify variable was NOT pushed due to skipVariables: true
const afterVariables = await backend.listAllVariables();
const variableExistsAfter = afterVariables.some(v => v.path === `u/admin/test_push_var_${timestamp}`);
assertEquals(variableExistsAfter, false, "Variable should NOT be pushed due to skipVariables override");
// Verify script WAS pushed (not affected by skipVariables)
const afterScripts = await backend.listAllScripts();
const scriptExistsAfter = afterScripts.some(s => s.path === `f/test/push_script_${timestamp}`);
assertEquals(scriptExistsAfter, true, "Script should be pushed normally");
});
});
Deno.test("Integration: sync pull respects includes override for file filtering", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "includes_test",
token: backend.token
};
await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir });
// Create wmill.yaml with override that only includes specific path
const backendUrl = new URL(backend.baseUrl).toString();
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
overrides:
"${backendUrl}:${backend.workspace}:u/test/test_repo":
includes:
- "u/admin/**" # Only include admin resources, exclude f/** apps/scripts`);
// Run sync pull to write files
const result = await backend.runCLICommand([
'sync', 'pull',
'--repository', 'u/test/test_repo',
'--yes'
], tempDir);
assertEquals(result.code, 0, `Sync pull should succeed: ${result.stderr}`);
// Verify admin files were written (since we have includes: ["u/admin/**"])
const adminFiles = [];
try {
for await (const entry of Deno.readDir(`${tempDir}/u/admin`)) {
if (entry.isFile) {
adminFiles.push(`u/admin/${entry.name}`);
}
}
} catch {
// Directory might not exist if no files matched
}
// We expect admin files to be written since backend has u/admin/test_config variable
assert(adminFiles.length > 0, `Admin files should be written due to includes override. Expected u/admin files but found: ${adminFiles.join(', ')}`);
// Verify f/** files were NOT written due to includes override
let fDirectoryExists = false;
try {
await Deno.stat(`${tempDir}/f`);
fDirectoryExists = true;
} catch {
// Directory doesn't exist, which is expected
}
assertEquals(fDirectoryExists, false, "f/ directory should not exist due to includes override excluding f/**");
});
});

View File

@@ -1,48 +0,0 @@
{
"workspace_id": "starter",
"name": "postgres",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"dbname",
"user",
"password"
],
"properties": {
"host": {
"type": "string",
"description": "The instance host"
},
"port": {
"type": "integer",
"description": "The instance port"
},
"user": {
"type": "string",
"description": "The postgres username"
},
"dbname": {
"type": "string",
"description": "The database name"
},
"sslmode": {
"enum": [
"disable",
"allow",
"prefer",
"require",
"verify-ca",
"verify-full"
],
"type": "string",
"description": "The sslmode"
},
"password": {
"type": "string",
"description": "The postgres users password"
}
}
},
"description": "A postgres database connection resource"
}

View File

@@ -1,16 +0,0 @@
{
"workspace_id": "starter",
"name": "slack",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [],
"properties": {
"token": {
"type": "string",
"description": "The slack token"
}
}
},
"description": "A slack token to interact with a specific workspace. Can be obtained from the OAuth integration in the workspace settings."
}

View File

@@ -1,16 +0,0 @@
{
"workspace_id": "starter",
"path": "g/all/demodb",
"value": {
"host": "demodb.service.consul",
"port": "6543",
"user": "postgres",
"dbname": "demodb",
"sslmode": "disable",
"password": "demodb"
},
"description": "demodb",
"resource_type": "postgres",
"extra_perms": {},
"is_oauth": false
}

View File

@@ -0,0 +1,147 @@
import { assertEquals, assertStringIncludes, assert } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { readConfigFile, getEffectiveSettings } from "../conf.ts";
import { withContainerizedBackend } from "./containerized_backend.ts";
import { addWorkspace } from "../workspace.ts";
import { parseJsonFromCLIOutput } from "./test_config_helpers.ts";
// =============================================================================
// SYNC CONFIGURATION RESOLUTION TESTS
// Tests for configuration resolution and integration with backend
// =============================================================================
// Helper function to set up workspace profile with localhost_test name
async function setupWorkspaceProfile(backend: any): Promise<void> {
const testWorkspace = {
remote: backend.baseUrl, // "http://localhost:8001/"
workspaceId: backend.workspace, // "test"
name: "localhost_test", // This is what the tests expect!
token: backend.token
};
await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir });
}
// =============================================================================
// INTEGRATION TESTS WITH REAL BACKEND
// =============================================================================
Deno.test("Integration: wmill.yaml configuration produces expected results", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace profile with name "localhost_test"
await setupWorkspaceProfile(backend);
// Create wmill.yaml with settings
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- f/**
- settings.yaml
excludes:
- "*.test.ts"
skipVariables: true
skipResources: true
includeSettings: true
includeSchedules: true
includeTriggers: true`);
// Test pull with wmill.yaml configuration
const yamlResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir);
if (yamlResult.code !== 0) {
console.log("YAML command failed!");
console.log("Exit code:", yamlResult.code);
console.log("Stdout:", yamlResult.stdout);
console.log("Stderr:", yamlResult.stderr);
}
assertEquals(yamlResult.code, 0);
// Extract JSON from CLI output (skip log messages)
const yamlData = parseJsonFromCLIOutput(yamlResult.stdout);
// Should include settings.yaml due to includeSettings: true
const hasSettings = (yamlData.changes || []).some((change: any) =>
change.type === 'added' && change.path === 'settings.yaml'
);
assertEquals(hasSettings, true);
// Should NOT include resources or variables (due to skip flags)
const hasResources = (yamlData.changes || []).some((change: any) =>
change.type === 'added' && change.path?.includes('.resource.yaml')
);
const hasVariables = (yamlData.changes || []).some((change: any) =>
change.type === 'added' && change.path?.includes('.variable.yaml')
);
assertEquals(hasResources, false);
assertEquals(hasVariables, false);
});
});
Deno.test("Integration: settings.yaml inclusion respects includeSettings flag", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace profile with name "localhost_test"
await setupWorkspaceProfile(backend);
// Test 1: includeSettings: true should include settings.yaml
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
includeSettings: true`);
const includeResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir);
assertEquals(includeResult.code, 0);
// Extract JSON from CLI output (skip log messages)
const includeData = parseJsonFromCLIOutput(includeResult.stdout);
const hasSettingsInclude = (includeData.changes || []).some((change: any) =>
change.type === 'added' && change.path === 'settings.yaml'
);
assertEquals(hasSettingsInclude, true);
// Test 2: includeSettings: false should NOT include settings.yaml
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
includeSettings: false`);
const excludeResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir);
assertEquals(excludeResult.code, 0);
// Extract JSON from CLI output (skip log messages)
const excludeData = parseJsonFromCLIOutput(excludeResult.stdout);
const hasSettingsExclude = (excludeData.changes || []).some((change: any) =>
change.type === 'added' && change.path === 'settings.yaml'
);
assertEquals(hasSettingsExclude, false);
});
});
Deno.test("Integration: resource/variable filtering respects skip flags", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace profile with name "localhost_test"
await setupWorkspaceProfile(backend);
// Test skipResources: true
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
skipResources: true
skipVariables: false`);
const result = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir);
assertEquals(result.code, 0);
// Extract JSON from CLI output (skip log messages)
const data = parseJsonFromCLIOutput(result.stdout);
// Should NOT include resources
const hasResources = (data.changes || []).some((change: any) =>
change.type === 'added' && change.path?.includes('.resource.yaml')
);
assertEquals(hasResources, false);
// Should include variables (not skipped)
const hasVariables = (data.changes || []).some((change: any) =>
change.type === 'added' && change.path?.includes('.variable.yaml')
);
assertEquals(hasVariables, true);
});
});

View File

@@ -0,0 +1,39 @@
import { getRootStore } from "../store.ts";
/**
* Create a temporary config directory for testing that doesn't interfere with user's config
*/
export async function withTestConfig<T>(callback: (testConfigDir: string) => Promise<T>): Promise<T> {
// Create a unique temporary directory for this test
const testDir = await Deno.makeTempDir({ prefix: "wmill_test_config_" });
try {
return await callback(testDir);
} finally {
// Clean up the temporary directory
try {
await Deno.remove(testDir, { recursive: true });
} catch (error) {
console.warn(`Failed to clean up test config directory ${testDir}:`, error);
}
}
}
/**
* Clear the remotes file in test config directory
*/
export async function clearTestRemotes(testConfigDir: string): Promise<void> {
const remoteFile = (await getRootStore(testConfigDir)) + "remotes.ndjson";
await Deno.writeTextFile(remoteFile, "");
}
/**
* Parse JSON output from CLI command, handling log messages that appear before JSON
*/
export function parseJsonFromCLIOutput(stdout: string): any {
const jsonMatch = stdout.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error(`No JSON found in CLI output: ${stdout}`);
}
return JSON.parse(jsonMatch[0]);
}

View File

@@ -0,0 +1,153 @@
import { assertEquals, assertRejects } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { addWorkspace, allWorkspaces } from "../workspace.ts";
import { withTestConfig, clearTestRemotes } from "./test_config_helpers.ts";
// Test workspace conflict detection
Deno.test("addWorkspace: prevents duplicate workspace names", async () => {
await withTestConfig(async (testConfigDir) => {
await clearTestRemotes(testConfigDir);
// Add first workspace
const workspace1 = {
name: "test_workspace",
remote: "http://localhost:8001/",
workspaceId: "workspace1",
token: "token1"
};
await addWorkspace(workspace1, { force: true, configDir: testConfigDir });
// Try to add workspace with same name but different details
const workspace2 = {
name: "test_workspace", // Same name
remote: "http://localhost:8002/", // Different remote
workspaceId: "workspace2", // Different ID
token: "token2"
};
// Should throw error in non-interactive mode without force
await assertRejects(
() => addWorkspace(workspace2, { configDir: testConfigDir }),
Error,
"Workspace name conflict. Use --force to overwrite or choose a different name."
);
// Should succeed with force flag
await addWorkspace(workspace2, { force: true, configDir: testConfigDir });
// Verify the workspace was overwritten
const workspaces = await allWorkspaces(testConfigDir);
assertEquals(workspaces.length, 1);
assertEquals(workspaces[0].name, "test_workspace");
assertEquals(workspaces[0].remote, "http://localhost:8002/");
assertEquals(workspaces[0].workspaceId, "workspace2");
});
});
Deno.test("addWorkspace: prevents duplicate (remote, workspaceId) tuples", async () => {
await withTestConfig(async (testConfigDir) => {
await clearTestRemotes(testConfigDir);
// Add first workspace
const workspace1 = {
name: "first_workspace",
remote: "http://localhost:8001/",
workspaceId: "test",
token: "token1"
};
await addWorkspace(workspace1, { force: true, configDir: testConfigDir });
// Try to add workspace with same (remote, workspaceId) but different name
const workspace2 = {
name: "second_workspace", // Different name
remote: "http://localhost:8001/", // Same remote
workspaceId: "test", // Same workspaceId
token: "token2"
};
// Should throw error in non-interactive mode without force
await assertRejects(
() => addWorkspace(workspace2, { configDir: testConfigDir }),
Error,
'Backend constraint violation: (http://localhost:8001/, test) already exists as "first_workspace". Use --force to overwrite.'
);
// Should succeed with force flag (overwrites first workspace)
await addWorkspace(workspace2, { force: true, configDir: testConfigDir });
// Verify the first workspace was removed and second was added
const workspaces = await allWorkspaces(testConfigDir);
assertEquals(workspaces.length, 1);
assertEquals(workspaces[0].name, "second_workspace");
assertEquals(workspaces[0].remote, "http://localhost:8001/");
assertEquals(workspaces[0].workspaceId, "test");
});
});
Deno.test("addWorkspace: allows same workspace (name, remote, workspaceId) with token update", async () => {
await withTestConfig(async (testConfigDir) => {
await clearTestRemotes(testConfigDir);
// Add first workspace
const workspace1 = {
name: "same_workspace",
remote: "http://localhost:8001/",
workspaceId: "test",
token: "old_token"
};
await addWorkspace(workspace1, { force: true, configDir: testConfigDir });
// Add same workspace with updated token
const workspace2 = {
name: "same_workspace", // Same name
remote: "http://localhost:8001/", // Same remote
workspaceId: "test", // Same workspaceId
token: "new_token" // Different token
};
// Should succeed without force (just token update)
await addWorkspace(workspace2, { configDir: testConfigDir });
// Verify token was updated
const workspaces = await allWorkspaces(testConfigDir);
assertEquals(workspaces.length, 1);
assertEquals(workspaces[0].name, "same_workspace");
assertEquals(workspaces[0].token, "new_token");
});
});
Deno.test("addWorkspace: allows different workspaces on different remotes", async () => {
await withTestConfig(async (testConfigDir) => {
await clearTestRemotes(testConfigDir);
// Add workspace on first remote
const workspace1 = {
name: "workspace_remote1",
remote: "http://localhost:8001/",
workspaceId: "test",
token: "token1"
};
await addWorkspace(workspace1, { force: true, configDir: testConfigDir });
// Add workspace with same workspaceId on different remote (should be allowed)
const workspace2 = {
name: "workspace_remote2",
remote: "http://localhost:8002/", // Different remote
workspaceId: "test", // Same workspaceId (OK on different remote)
token: "token2"
};
// Should succeed (different remotes)
await addWorkspace(workspace2, { configDir: testConfigDir });
// Verify both workspaces exist
const workspaces = await allWorkspaces(testConfigDir);
assertEquals(workspaces.length, 2);
const names = workspaces.map(w => w.name).sort();
assertEquals(names, ["workspace_remote1", "workspace_remote2"]);
});
});