takos-runtime-service 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/package.json +29 -0
  2. package/src/__tests__/middleware/rate-limit.test.ts +33 -0
  3. package/src/__tests__/middleware/workspace-scope-extended.test.ts +163 -0
  4. package/src/__tests__/routes/actions-start-limits.test.ts +139 -0
  5. package/src/__tests__/routes/actions-step-warnings.test.ts +194 -0
  6. package/src/__tests__/routes/cli-proxy.test.ts +72 -0
  7. package/src/__tests__/routes/git-http.test.ts +218 -0
  8. package/src/__tests__/routes/git-lfs-policy.test.ts +112 -0
  9. package/src/__tests__/routes/sessions/store.test.ts +72 -0
  10. package/src/__tests__/routes/workspace-scope.test.ts +45 -0
  11. package/src/__tests__/runtime/action-registry.test.ts +208 -0
  12. package/src/__tests__/runtime/action-result-helpers.test.ts +129 -0
  13. package/src/__tests__/runtime/actions/executor.test.ts +131 -0
  14. package/src/__tests__/runtime/composite-expression.test.ts +294 -0
  15. package/src/__tests__/runtime/file-parsers.test.ts +129 -0
  16. package/src/__tests__/runtime/logging.test.ts +65 -0
  17. package/src/__tests__/runtime/paths.test.ts +236 -0
  18. package/src/__tests__/runtime/secrets.test.ts +247 -0
  19. package/src/__tests__/runtime/validation.test.ts +516 -0
  20. package/src/__tests__/setup.ts +126 -0
  21. package/src/__tests__/shared/errors.test.ts +117 -0
  22. package/src/__tests__/storage/r2.test.ts +106 -0
  23. package/src/__tests__/utils/audit-log.test.ts +163 -0
  24. package/src/__tests__/utils/error-message.test.ts +38 -0
  25. package/src/__tests__/utils/sandbox-env.test.ts +74 -0
  26. package/src/app.ts +245 -0
  27. package/src/index.ts +1 -0
  28. package/src/middleware/rate-limit.ts +91 -0
  29. package/src/middleware/space-scope.ts +95 -0
  30. package/src/routes/actions/action-types.ts +20 -0
  31. package/src/routes/actions/execution.ts +229 -0
  32. package/src/routes/actions/index.ts +17 -0
  33. package/src/routes/actions/job-lifecycle.ts +242 -0
  34. package/src/routes/actions/job-queries.ts +52 -0
  35. package/src/routes/cli/proxy.ts +105 -0
  36. package/src/routes/git/http.ts +565 -0
  37. package/src/routes/git/init.ts +88 -0
  38. package/src/routes/repos/branches.ts +160 -0
  39. package/src/routes/repos/content.ts +209 -0
  40. package/src/routes/repos/read.ts +130 -0
  41. package/src/routes/repos/repo-validation.ts +136 -0
  42. package/src/routes/repos/write.ts +274 -0
  43. package/src/routes/runtime/exec.ts +147 -0
  44. package/src/routes/runtime/tools.ts +113 -0
  45. package/src/routes/sessions/execution.ts +263 -0
  46. package/src/routes/sessions/files.ts +326 -0
  47. package/src/routes/sessions/session-routes.ts +241 -0
  48. package/src/routes/sessions/session-utils.ts +88 -0
  49. package/src/routes/sessions/snapshot.ts +208 -0
  50. package/src/routes/sessions/storage.ts +329 -0
  51. package/src/runtime/actions/action-registry.ts +450 -0
  52. package/src/runtime/actions/action-result-converter.ts +31 -0
  53. package/src/runtime/actions/builtin/artifacts.ts +292 -0
  54. package/src/runtime/actions/builtin/cache-operations.ts +358 -0
  55. package/src/runtime/actions/builtin/checkout.ts +58 -0
  56. package/src/runtime/actions/builtin/index.ts +5 -0
  57. package/src/runtime/actions/builtin/setup-node.ts +86 -0
  58. package/src/runtime/actions/builtin/tar-parser.ts +175 -0
  59. package/src/runtime/actions/composite-executor.ts +192 -0
  60. package/src/runtime/actions/composite-expression.ts +190 -0
  61. package/src/runtime/actions/executor.ts +578 -0
  62. package/src/runtime/actions/file-parsers.ts +51 -0
  63. package/src/runtime/actions/job-manager.ts +213 -0
  64. package/src/runtime/actions/process-spawner.ts +275 -0
  65. package/src/runtime/actions/secrets.ts +162 -0
  66. package/src/runtime/command.ts +120 -0
  67. package/src/runtime/exec-runner.ts +309 -0
  68. package/src/runtime/git-http-backend.ts +145 -0
  69. package/src/runtime/git.ts +98 -0
  70. package/src/runtime/heartbeat.ts +57 -0
  71. package/src/runtime/logging.ts +26 -0
  72. package/src/runtime/paths.ts +264 -0
  73. package/src/runtime/secure-fs.ts +82 -0
  74. package/src/runtime/tools/network.ts +161 -0
  75. package/src/runtime/tools/worker.ts +335 -0
  76. package/src/runtime/validation.ts +292 -0
  77. package/src/shared/config.ts +149 -0
  78. package/src/shared/errors.ts +65 -0
  79. package/src/shared/temp-id.ts +10 -0
  80. package/src/storage/r2.ts +287 -0
  81. package/src/types/hono.d.ts +23 -0
  82. package/src/utils/audit-log.ts +92 -0
  83. package/src/utils/process-kill.ts +18 -0
  84. package/src/utils/sandbox-env.ts +136 -0
  85. package/src/utils/temp-dir.ts +74 -0
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { appendOutput, buildCombinedResult } from '../../runtime/actions/action-result-converter.js';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // appendOutput
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe('appendOutput', () => {
9
+ it('appends stdout and stderr', () => {
10
+ const stdoutParts: string[] = [];
11
+ const stderrParts: string[] = [];
12
+
13
+ appendOutput(
14
+ { exitCode: 0, stdout: 'out1', stderr: 'err1', outputs: {}, conclusion: 'success' },
15
+ stdoutParts,
16
+ stderrParts,
17
+ );
18
+
19
+ expect(stdoutParts).toEqual(['out1']);
20
+ expect(stderrParts).toEqual(['err1']);
21
+ });
22
+
23
+ it('skips empty stdout', () => {
24
+ const stdoutParts: string[] = [];
25
+ const stderrParts: string[] = [];
26
+
27
+ appendOutput(
28
+ { exitCode: 0, stdout: '', stderr: 'err', outputs: {}, conclusion: 'success' },
29
+ stdoutParts,
30
+ stderrParts,
31
+ );
32
+
33
+ expect(stdoutParts).toEqual([]);
34
+ expect(stderrParts).toEqual(['err']);
35
+ });
36
+
37
+ it('skips empty stderr', () => {
38
+ const stdoutParts: string[] = [];
39
+ const stderrParts: string[] = [];
40
+
41
+ appendOutput(
42
+ { exitCode: 0, stdout: 'out', stderr: '', outputs: {}, conclusion: 'success' },
43
+ stdoutParts,
44
+ stderrParts,
45
+ );
46
+
47
+ expect(stdoutParts).toEqual(['out']);
48
+ expect(stderrParts).toEqual([]);
49
+ });
50
+
51
+ it('accumulates multiple results', () => {
52
+ const stdoutParts: string[] = [];
53
+ const stderrParts: string[] = [];
54
+
55
+ appendOutput(
56
+ { exitCode: 0, stdout: 'out1', stderr: 'err1', outputs: {}, conclusion: 'success' },
57
+ stdoutParts,
58
+ stderrParts,
59
+ );
60
+ appendOutput(
61
+ { exitCode: 0, stdout: 'out2', stderr: 'err2', outputs: {}, conclusion: 'success' },
62
+ stdoutParts,
63
+ stderrParts,
64
+ );
65
+
66
+ expect(stdoutParts).toEqual(['out1', 'out2']);
67
+ expect(stderrParts).toEqual(['err1', 'err2']);
68
+ });
69
+
70
+ it('handles undefined stdout/stderr', () => {
71
+ const stdoutParts: string[] = [];
72
+ const stderrParts: string[] = [];
73
+
74
+ appendOutput(
75
+ { exitCode: 0, stdout: undefined as any, stderr: undefined as any, outputs: {}, conclusion: 'success' },
76
+ stdoutParts,
77
+ stderrParts,
78
+ );
79
+
80
+ expect(stdoutParts).toEqual([]);
81
+ expect(stderrParts).toEqual([]);
82
+ });
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // buildCombinedResult
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe('buildCombinedResult', () => {
90
+ it('builds success result', () => {
91
+ const result = buildCombinedResult(
92
+ ['out1', 'out2'],
93
+ ['err1'],
94
+ { key: 'value' },
95
+ 'success',
96
+ );
97
+
98
+ expect(result).toEqual({
99
+ exitCode: 0,
100
+ stdout: 'out1\nout2',
101
+ stderr: 'err1',
102
+ outputs: { key: 'value' },
103
+ conclusion: 'success',
104
+ });
105
+ });
106
+
107
+ it('builds failure result with exit code 1', () => {
108
+ const result = buildCombinedResult([], [], {}, 'failure');
109
+ expect(result.exitCode).toBe(1);
110
+ expect(result.conclusion).toBe('failure');
111
+ });
112
+
113
+ it('trims trailing whitespace from joined output', () => {
114
+ const result = buildCombinedResult(['line1 ', 'line2 '], [], {}, 'success');
115
+ expect(result.stdout).toBe('line1 \nline2');
116
+ });
117
+
118
+ it('handles empty arrays', () => {
119
+ const result = buildCombinedResult([], [], {}, 'success');
120
+ expect(result.stdout).toBe('');
121
+ expect(result.stderr).toBe('');
122
+ });
123
+
124
+ it('preserves outputs object', () => {
125
+ const outputs = { a: '1', b: '2' };
126
+ const result = buildCombinedResult([], [], outputs, 'success');
127
+ expect(result.outputs).toEqual({ a: '1', b: '2' });
128
+ });
129
+ });
@@ -0,0 +1,131 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import path from 'path';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+
6
+ vi.mock('../../../runtime/actions/sandbox.js', () => ({
7
+ validateCommand: vi.fn(() => null),
8
+ }));
9
+
10
+ vi.mock('../../../runtime/actions/builtin/index.js', () => ({
11
+ checkout: vi.fn(),
12
+ setupNode: vi.fn(),
13
+ cache: vi.fn(),
14
+ uploadArtifact: vi.fn(),
15
+ downloadArtifact: vi.fn(),
16
+ }));
17
+
18
+ vi.mock('../../../shared/config.js', () => ({
19
+ REPOS_BASE_DIR: '/tmp/takos-runtime-test-repos',
20
+ WORKDIR_BASE_DIR: '/tmp',
21
+ MAX_LOG_LINES: 100_000,
22
+ ALLOWED_COMMANDS_SET: new Set(['node', 'npm', 'git', 'bash', 'sh', 'echo', 'ls', 'cat']),
23
+ COMMAND_BLOCKLIST_PATTERNS: [],
24
+ MAX_CONCURRENT_EXEC_PER_WORKSPACE: 5,
25
+ GIT_ENDPOINT_URL: 'https://git.takos.dev',
26
+ TAKOS_API_URL: 'https://test.takos.jp',
27
+ HEARTBEAT_INTERVAL_MS: 30_000,
28
+ R2_BUCKET: 'test-bucket',
29
+ SANDBOX_LIMITS: {
30
+ maxExecutionTime: 30_000,
31
+ maxOutputSize: 1024 * 1024,
32
+ },
33
+ }));
34
+
35
+ import { StepExecutor } from '../../../runtime/actions/executor.js';
36
+
37
+ async function createTempDir(prefix: string): Promise<string> {
38
+ return fs.mkdtemp(path.join(os.tmpdir(), prefix));
39
+ }
40
+
41
+ async function writeCompositeAction(actionDir: string, workingDirectory: string): Promise<void> {
42
+ const actionContent = `name: test-action
43
+ runs:
44
+ using: composite
45
+ steps:
46
+ - run: echo hello
47
+ working-directory: ${workingDirectory}
48
+ `;
49
+ await fs.mkdir(actionDir, { recursive: true });
50
+ await fs.writeFile(path.join(actionDir, 'action.yml'), actionContent, 'utf-8');
51
+ }
52
+
53
+ async function writeNodeAction(actionDir: string, main: string): Promise<void> {
54
+ const actionContent = `name: test-node-action
55
+ runs:
56
+ using: node20
57
+ main: ${main}
58
+ `;
59
+ await fs.mkdir(actionDir, { recursive: true });
60
+ await fs.writeFile(path.join(actionDir, 'action.yml'), actionContent, 'utf-8');
61
+ }
62
+
63
+ describe('StepExecutor composite working-directory boundary checks', () => {
64
+ it('fails when working-directory symlink resolves outside workspace/action boundary', async () => {
65
+ const workspaceDir = await createTempDir('takos-executor-ws-');
66
+ const outsideDir = await createTempDir('takos-executor-outside-');
67
+ const actionDir = path.join(workspaceDir, 'action');
68
+ const escapeLink = path.join(workspaceDir, 'escape-link');
69
+
70
+ try {
71
+ await fs.symlink(outsideDir, escapeLink);
72
+ await writeCompositeAction(actionDir, 'escape-link');
73
+
74
+ const executor = new StepExecutor(workspaceDir, { PATH: process.env.PATH || '' });
75
+ const result = await executor.executeAction('./action', {});
76
+
77
+ expect(result.conclusion).toBe('failure');
78
+ expect(result.stderr).toContain('Invalid working directory');
79
+ } finally {
80
+ await fs.rm(workspaceDir, { recursive: true, force: true });
81
+ await fs.rm(outsideDir, { recursive: true, force: true });
82
+ }
83
+ });
84
+
85
+ it('allows symlinked working-directory when the resolved path stays inside workspace', async () => {
86
+ const workspaceDir = await createTempDir('takos-executor-safe-ws-');
87
+ const actionDir = path.join(workspaceDir, 'action');
88
+ const safeTargetDir = path.join(workspaceDir, 'safe-target');
89
+ const safeLink = path.join(workspaceDir, 'safe-link');
90
+
91
+ try {
92
+ await fs.mkdir(safeTargetDir, { recursive: true });
93
+ await fs.symlink(safeTargetDir, safeLink);
94
+ await writeCompositeAction(actionDir, 'safe-link');
95
+
96
+ const executor = new StepExecutor(workspaceDir, { PATH: process.env.PATH || '' });
97
+ const result = await executor.executeAction('./action', {});
98
+
99
+ expect(result.conclusion).toBe('success');
100
+ expect(result.exitCode).toBe(0);
101
+ } finally {
102
+ await fs.rm(workspaceDir, { recursive: true, force: true });
103
+ }
104
+ });
105
+ });
106
+
107
+ describe('StepExecutor node action script boundary checks', () => {
108
+ it('fails when the main script symlink resolves outside action directory', async () => {
109
+ const workspaceDir = await createTempDir('takos-executor-node-ws-');
110
+ const outsideDir = await createTempDir('takos-executor-node-outside-');
111
+ const actionDir = path.join(workspaceDir, 'action');
112
+ const outsideScript = path.join(outsideDir, 'main.js');
113
+ const scriptLink = path.join(actionDir, 'main.js');
114
+
115
+ try {
116
+ await fs.mkdir(actionDir, { recursive: true });
117
+ await fs.writeFile(outsideScript, 'console.log("outside");\n', 'utf-8');
118
+ await fs.symlink(outsideScript, scriptLink);
119
+ await writeNodeAction(actionDir, 'main.js');
120
+
121
+ const executor = new StepExecutor(workspaceDir, { PATH: process.env.PATH || '' });
122
+ const result = await executor.executeAction('./action', {});
123
+
124
+ expect(result.conclusion).toBe('failure');
125
+ expect(result.stderr).toContain('Node action main script escapes action directory');
126
+ } finally {
127
+ await fs.rm(workspaceDir, { recursive: true, force: true });
128
+ await fs.rm(outsideDir, { recursive: true, force: true });
129
+ }
130
+ });
131
+ });
@@ -0,0 +1,294 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ resolveExpressionValue,
4
+ interpolateString,
5
+ evaluateCondition,
6
+ normalizeInputValue,
7
+ resolveEnv,
8
+ resolveWith,
9
+ resolveCompositeOutputs,
10
+ type InterpolationContext,
11
+ } from '../../runtime/actions/composite-expression.js';
12
+
13
+ function makeContext(overrides: Partial<InterpolationContext> = {}): InterpolationContext {
14
+ return {
15
+ inputs: { name: 'world', debug: 'true' },
16
+ env: {
17
+ CI: 'true',
18
+ GITHUB_WORKSPACE: '/home/runner/work',
19
+ GITHUB_REF: 'refs/heads/main',
20
+ GITHUB_SHA: 'abc123',
21
+ },
22
+ steps: {
23
+ build: { status: 'success', output: 'built' },
24
+ },
25
+ jobStatus: 'success',
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // resolveExpressionValue
32
+ // ---------------------------------------------------------------------------
33
+
34
+ describe('resolveExpressionValue', () => {
35
+ it('resolves "true" literal', () => {
36
+ expect(resolveExpressionValue('true', makeContext())).toBe('true');
37
+ });
38
+
39
+ it('resolves "false" literal', () => {
40
+ expect(resolveExpressionValue('false', makeContext())).toBe('false');
41
+ });
42
+
43
+ it('resolves inputs', () => {
44
+ expect(resolveExpressionValue('inputs.name', makeContext())).toBe('world');
45
+ });
46
+
47
+ it('returns empty string for missing input', () => {
48
+ expect(resolveExpressionValue('inputs.missing', makeContext())).toBe('');
49
+ });
50
+
51
+ it('resolves env values', () => {
52
+ expect(resolveExpressionValue('env.CI', makeContext())).toBe('true');
53
+ });
54
+
55
+ it('returns empty string for missing env', () => {
56
+ expect(resolveExpressionValue('env.MISSING', makeContext())).toBe('');
57
+ });
58
+
59
+ it('resolves step outputs', () => {
60
+ expect(resolveExpressionValue('steps.build.outputs.output', makeContext())).toBe('built');
61
+ });
62
+
63
+ it('returns empty string for missing step', () => {
64
+ expect(resolveExpressionValue('steps.missing.outputs.x', makeContext())).toBe('');
65
+ });
66
+
67
+ it('resolves github.workspace', () => {
68
+ expect(resolveExpressionValue('github.workspace', makeContext())).toBe('/home/runner/work');
69
+ });
70
+
71
+ it('resolves github.ref', () => {
72
+ expect(resolveExpressionValue('github.ref', makeContext())).toBe('refs/heads/main');
73
+ });
74
+
75
+ it('resolves github.sha', () => {
76
+ expect(resolveExpressionValue('github.sha', makeContext())).toBe('abc123');
77
+ });
78
+
79
+ it('returns undefined for unknown github context key', () => {
80
+ expect(resolveExpressionValue('github.unknown', makeContext())).toBeUndefined();
81
+ });
82
+
83
+ it('returns undefined for unknown expression prefix', () => {
84
+ expect(resolveExpressionValue('unknown.value', makeContext())).toBeUndefined();
85
+ });
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // interpolateString
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe('interpolateString', () => {
93
+ it('interpolates input references', () => {
94
+ expect(interpolateString('Hello ${{ inputs.name }}!', makeContext())).toBe('Hello world!');
95
+ });
96
+
97
+ it('interpolates env references', () => {
98
+ expect(interpolateString('CI=${{ env.CI }}', makeContext())).toBe('CI=true');
99
+ });
100
+
101
+ it('replaces unknown expressions with empty string', () => {
102
+ expect(interpolateString('${{ unknown.ref }}', makeContext())).toBe('');
103
+ });
104
+
105
+ it('handles multiple expressions', () => {
106
+ expect(
107
+ interpolateString('${{ inputs.name }} on ${{ github.ref }}', makeContext()),
108
+ ).toBe('world on refs/heads/main');
109
+ });
110
+
111
+ it('returns original string with no expressions', () => {
112
+ expect(interpolateString('no expressions here', makeContext())).toBe('no expressions here');
113
+ });
114
+
115
+ it('handles whitespace in expressions', () => {
116
+ expect(interpolateString('${{ inputs.name }}', makeContext())).toBe('world');
117
+ });
118
+
119
+ it('handles step output references', () => {
120
+ expect(
121
+ interpolateString('result=${{ steps.build.outputs.output }}', makeContext()),
122
+ ).toBe('result=built');
123
+ });
124
+ });
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // evaluateCondition
128
+ // ---------------------------------------------------------------------------
129
+
130
+ describe('evaluateCondition', () => {
131
+ it('returns true for empty condition', () => {
132
+ expect(evaluateCondition('', makeContext())).toBe(true);
133
+ });
134
+
135
+ it('evaluates always() as true', () => {
136
+ expect(evaluateCondition('always()', makeContext())).toBe(true);
137
+ });
138
+
139
+ it('evaluates cancelled() as false', () => {
140
+ expect(evaluateCondition('cancelled()', makeContext())).toBe(false);
141
+ });
142
+
143
+ it('evaluates success() as true when jobStatus is success', () => {
144
+ expect(evaluateCondition('success()', makeContext({ jobStatus: 'success' }))).toBe(true);
145
+ });
146
+
147
+ it('evaluates success() as false when jobStatus is failure', () => {
148
+ expect(evaluateCondition('success()', makeContext({ jobStatus: 'failure' }))).toBe(false);
149
+ });
150
+
151
+ it('evaluates failure() as true when jobStatus is failure', () => {
152
+ expect(evaluateCondition('failure()', makeContext({ jobStatus: 'failure' }))).toBe(true);
153
+ });
154
+
155
+ it('evaluates failure() as false when jobStatus is success', () => {
156
+ expect(evaluateCondition('failure()', makeContext({ jobStatus: 'success' }))).toBe(false);
157
+ });
158
+
159
+ it('evaluates negation', () => {
160
+ expect(evaluateCondition('!failure()', makeContext({ jobStatus: 'success' }))).toBe(true);
161
+ expect(evaluateCondition('!failure()', makeContext({ jobStatus: 'failure' }))).toBe(false);
162
+ });
163
+
164
+ it('evaluates equality comparison', () => {
165
+ expect(evaluateCondition("inputs.debug == 'true'", makeContext())).toBe(true);
166
+ expect(evaluateCondition("inputs.debug == 'false'", makeContext())).toBe(false);
167
+ });
168
+
169
+ it('evaluates inequality comparison', () => {
170
+ expect(evaluateCondition("inputs.debug != 'false'", makeContext())).toBe(true);
171
+ expect(evaluateCondition("inputs.debug != 'true'", makeContext())).toBe(false);
172
+ });
173
+
174
+ it('strips ${{ }} wrapper from condition', () => {
175
+ expect(evaluateCondition('${{ always() }}', makeContext())).toBe(true);
176
+ });
177
+
178
+ it('evaluates truthy expression value', () => {
179
+ expect(evaluateCondition('inputs.name', makeContext())).toBe(true);
180
+ });
181
+
182
+ it('evaluates falsy expression value', () => {
183
+ expect(evaluateCondition('inputs.missing', makeContext())).toBe(false);
184
+ });
185
+
186
+ it('handles comparison with double quotes', () => {
187
+ expect(evaluateCondition('inputs.name == "world"', makeContext())).toBe(true);
188
+ });
189
+ });
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // normalizeInputValue
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe('normalizeInputValue', () => {
196
+ it('converts null to empty string', () => {
197
+ expect(normalizeInputValue(null)).toBe('');
198
+ });
199
+
200
+ it('converts undefined to empty string', () => {
201
+ expect(normalizeInputValue(undefined)).toBe('');
202
+ });
203
+
204
+ it('converts true to "true"', () => {
205
+ expect(normalizeInputValue(true)).toBe('true');
206
+ });
207
+
208
+ it('converts false to "false"', () => {
209
+ expect(normalizeInputValue(false)).toBe('false');
210
+ });
211
+
212
+ it('converts number to string', () => {
213
+ expect(normalizeInputValue(42)).toBe('42');
214
+ });
215
+
216
+ it('passes string through', () => {
217
+ expect(normalizeInputValue('hello')).toBe('hello');
218
+ });
219
+ });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // resolveEnv
223
+ // ---------------------------------------------------------------------------
224
+
225
+ describe('resolveEnv', () => {
226
+ it('returns empty object for undefined env', () => {
227
+ expect(resolveEnv(undefined, makeContext())).toEqual({});
228
+ });
229
+
230
+ it('interpolates string values', () => {
231
+ const result = resolveEnv({ MY_VAR: '${{ inputs.name }}' }, makeContext());
232
+ expect(result).toEqual({ MY_VAR: 'world' });
233
+ });
234
+
235
+ it('passes non-expression strings through', () => {
236
+ const result = resolveEnv({ STATIC: 'value' }, makeContext());
237
+ expect(result).toEqual({ STATIC: 'value' });
238
+ });
239
+ });
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // resolveWith
243
+ // ---------------------------------------------------------------------------
244
+
245
+ describe('resolveWith', () => {
246
+ it('returns empty object for undefined input', () => {
247
+ expect(resolveWith(undefined, makeContext())).toEqual({});
248
+ });
249
+
250
+ it('interpolates string values', () => {
251
+ const result = resolveWith({ name: '${{ inputs.name }}' }, makeContext());
252
+ expect(result).toEqual({ name: 'world' });
253
+ });
254
+
255
+ it('passes non-string values through', () => {
256
+ const result = resolveWith({ count: 42 as any }, makeContext());
257
+ expect(result).toEqual({ count: 42 });
258
+ });
259
+ });
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // resolveCompositeOutputs
263
+ // ---------------------------------------------------------------------------
264
+
265
+ describe('resolveCompositeOutputs', () => {
266
+ it('returns empty object for undefined outputs', () => {
267
+ expect(resolveCompositeOutputs(undefined, makeContext())).toEqual({});
268
+ });
269
+
270
+ it('interpolates output values', () => {
271
+ const outputs = {
272
+ result: { value: '${{ steps.build.outputs.output }}' },
273
+ };
274
+ const result = resolveCompositeOutputs(outputs, makeContext());
275
+ expect(result).toEqual({ result: 'built' });
276
+ });
277
+
278
+ it('skips outputs without value', () => {
279
+ const outputs = {
280
+ noValue: { description: 'No value set' },
281
+ };
282
+ const result = resolveCompositeOutputs(outputs, makeContext());
283
+ expect(result).toEqual({});
284
+ });
285
+
286
+ it('handles multiple outputs', () => {
287
+ const outputs = {
288
+ a: { value: '${{ inputs.name }}' },
289
+ b: { value: 'static' },
290
+ };
291
+ const result = resolveCompositeOutputs(outputs, makeContext());
292
+ expect(result).toEqual({ a: 'world', b: 'static' });
293
+ });
294
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseKeyValueFile, parsePathFile } from '../../runtime/actions/file-parsers.js';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // parseKeyValueFile
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe('parseKeyValueFile', () => {
9
+ it('parses simple key=value pairs', () => {
10
+ expect(parseKeyValueFile('KEY=value')).toEqual({ KEY: 'value' });
11
+ });
12
+
13
+ it('parses multiple key=value pairs', () => {
14
+ const content = 'KEY1=value1\nKEY2=value2\nKEY3=value3';
15
+ expect(parseKeyValueFile(content)).toEqual({
16
+ KEY1: 'value1',
17
+ KEY2: 'value2',
18
+ KEY3: 'value3',
19
+ });
20
+ });
21
+
22
+ it('handles empty value', () => {
23
+ expect(parseKeyValueFile('KEY=')).toEqual({ KEY: '' });
24
+ });
25
+
26
+ it('handles value containing equals sign', () => {
27
+ expect(parseKeyValueFile('KEY=a=b=c')).toEqual({ KEY: 'a=b=c' });
28
+ });
29
+
30
+ it('handles heredoc format', () => {
31
+ const content = 'OUTPUT<<EOF\nline1\nline2\nEOF';
32
+ expect(parseKeyValueFile(content)).toEqual({ OUTPUT: 'line1\nline2' });
33
+ });
34
+
35
+ it('handles heredoc with custom delimiter', () => {
36
+ const content = 'DATA<<DELIM\ncontent here\nDELIM';
37
+ expect(parseKeyValueFile(content)).toEqual({ DATA: 'content here' });
38
+ });
39
+
40
+ it('handles empty heredoc', () => {
41
+ const content = 'EMPTY<<EOF\nEOF';
42
+ expect(parseKeyValueFile(content)).toEqual({ EMPTY: '' });
43
+ });
44
+
45
+ it('handles CRLF line endings', () => {
46
+ const content = 'KEY1=value1\r\nKEY2=value2';
47
+ expect(parseKeyValueFile(content)).toEqual({
48
+ KEY1: 'value1',
49
+ KEY2: 'value2',
50
+ });
51
+ });
52
+
53
+ it('skips empty lines', () => {
54
+ const content = 'KEY1=value1\n\n\nKEY2=value2';
55
+ expect(parseKeyValueFile(content)).toEqual({
56
+ KEY1: 'value1',
57
+ KEY2: 'value2',
58
+ });
59
+ });
60
+
61
+ it('skips lines without equals sign', () => {
62
+ const content = 'noequals\nKEY=value';
63
+ expect(parseKeyValueFile(content)).toEqual({ KEY: 'value' });
64
+ });
65
+
66
+ it('handles mixed heredoc and regular entries', () => {
67
+ const content = 'SIMPLE=val\nHERE<<EOF\nmulti\nline\nEOF\nAFTER=done';
68
+ expect(parseKeyValueFile(content)).toEqual({
69
+ SIMPLE: 'val',
70
+ HERE: 'multi\nline',
71
+ AFTER: 'done',
72
+ });
73
+ });
74
+
75
+ it('handles empty input', () => {
76
+ expect(parseKeyValueFile('')).toEqual({});
77
+ });
78
+
79
+ it('last value wins for duplicate keys', () => {
80
+ const content = 'KEY=first\nKEY=second';
81
+ expect(parseKeyValueFile(content)).toEqual({ KEY: 'second' });
82
+ });
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // parsePathFile
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe('parsePathFile', () => {
90
+ it('parses path entries', () => {
91
+ expect(parsePathFile('/usr/bin\n/home/user/.local/bin')).toEqual([
92
+ '/usr/bin',
93
+ '/home/user/.local/bin',
94
+ ]);
95
+ });
96
+
97
+ it('trims whitespace', () => {
98
+ expect(parsePathFile(' /usr/bin \n /home/bin ')).toEqual([
99
+ '/usr/bin',
100
+ '/home/bin',
101
+ ]);
102
+ });
103
+
104
+ it('filters empty lines', () => {
105
+ expect(parsePathFile('/usr/bin\n\n\n/home/bin\n')).toEqual([
106
+ '/usr/bin',
107
+ '/home/bin',
108
+ ]);
109
+ });
110
+
111
+ it('handles CRLF line endings', () => {
112
+ expect(parsePathFile('/usr/bin\r\n/home/bin')).toEqual([
113
+ '/usr/bin',
114
+ '/home/bin',
115
+ ]);
116
+ });
117
+
118
+ it('returns empty array for empty input', () => {
119
+ expect(parsePathFile('')).toEqual([]);
120
+ });
121
+
122
+ it('returns empty array for whitespace-only input', () => {
123
+ expect(parsePathFile(' \n \n ')).toEqual([]);
124
+ });
125
+
126
+ it('handles single path entry', () => {
127
+ expect(parsePathFile('/single/path')).toEqual(['/single/path']);
128
+ });
129
+ });