@google-cloud/cloud-run-mcp 1.6.0 → 1.8.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.
- package/.github/workflows/npm-publish.yml +1 -1
- package/CONTRIBUTING.md +1 -1
- package/lib/clients.js +222 -0
- package/lib/cloud-api/billing.js +90 -4
- package/lib/cloud-api/build.js +96 -49
- package/lib/cloud-api/helpers.js +60 -38
- package/lib/cloud-api/projects.js +4 -6
- package/lib/cloud-api/registry.js +5 -5
- package/lib/cloud-api/run.js +14 -33
- package/lib/cloud-api/storage.js +6 -7
- package/lib/deployment/constants.js +54 -0
- package/lib/deployment/deployer.js +251 -149
- package/lib/deployment/helpers.js +194 -0
- package/lib/deployment/source-processor.js +124 -0
- package/lib/util/archive.js +15 -6
- package/package.json +3 -3
- package/test/local/clients.test.js +146 -0
- package/test/local/cloud-api/build.test.js +293 -309
- package/test/local/deployment-helpers.test.js +231 -0
- package/test/local/mcp-server-stdio.test.js +59 -0
- package/test/local/mcp-server-streamable-http.test.js +25 -21
- package/test/local/mock-clients.js +35 -0
- package/test/local/source-processor.test.js +290 -0
- package/test/local/test-utils.js +51 -0
- package/test/need-gcp/test-helpers.js +1 -1
- package/test/need-gcp/workflows/deployment-workflows.test.js +3 -3
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2025 Google LLC
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { test, describe, mock } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import esmock from 'esmock';
|
|
20
|
+
import { DEPLOYMENT_CONFIG } from '../../lib/deployment/constants.js';
|
|
21
|
+
|
|
22
|
+
describe('Deployment Helpers', () => {
|
|
23
|
+
const fsMock = {
|
|
24
|
+
statSync: mock.fn(),
|
|
25
|
+
existsSync: mock.fn(),
|
|
26
|
+
readFileSync: mock.fn(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
test('makeFileDeploymentMetadata correctly identifies Dockerfile in folder', async () => {
|
|
30
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
31
|
+
fs: fsMock,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const folderPath = '/absolute/path/to/folder';
|
|
35
|
+
|
|
36
|
+
// Mock isFolder logic
|
|
37
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
38
|
+
isDirectory: () => true,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// Mock Dockerfile check
|
|
42
|
+
fsMock.existsSync.mock.mockImplementation((filePath) => {
|
|
43
|
+
if (filePath.endsWith('Dockerfile') || filePath.endsWith('dockerfile'))
|
|
44
|
+
return true;
|
|
45
|
+
return false;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
|
|
49
|
+
|
|
50
|
+
assert.equal(result.hasDockerfile, true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('makeFileDeploymentMetadata correctly identifies Node.js project attributes', async () => {
|
|
54
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
55
|
+
fs: fsMock,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const folderPath = '/absolute/path/to/node-app';
|
|
59
|
+
|
|
60
|
+
// Mock isFolder
|
|
61
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
62
|
+
isDirectory: () => true,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// Mock existsSync
|
|
66
|
+
fsMock.existsSync.mock.mockImplementation((p) => {
|
|
67
|
+
if (p.endsWith('Dockerfile')) return false;
|
|
68
|
+
if (p.endsWith('package.json')) return true;
|
|
69
|
+
return false;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Mock readFileSync for package.json
|
|
73
|
+
fsMock.readFileSync.mock.mockImplementation(() =>
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
scripts: { start: 'node server.js' },
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
|
|
80
|
+
|
|
81
|
+
assert.equal(result.hasDockerfile, false);
|
|
82
|
+
assert.equal(result.deploymentAttrs.runtime, 'nodejs');
|
|
83
|
+
assert.deepEqual(result.deploymentAttrs.cmd, ['node']);
|
|
84
|
+
assert.deepEqual(result.deploymentAttrs.args, ['server.js']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('makeFileDeploymentMetadata ignores Node.js if engines.node is present', async () => {
|
|
88
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
89
|
+
fs: fsMock,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const folderPath = '/absolute/path/to/node-app-engines';
|
|
93
|
+
|
|
94
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
95
|
+
isDirectory: () => true,
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
fsMock.existsSync.mock.mockImplementation((p) => {
|
|
99
|
+
if (p.endsWith('package.json')) return true;
|
|
100
|
+
return false;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
fsMock.readFileSync.mock.mockImplementation(() =>
|
|
104
|
+
JSON.stringify({
|
|
105
|
+
scripts: { start: 'node server.js' },
|
|
106
|
+
engines: { node: '>=14' },
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
|
|
111
|
+
|
|
112
|
+
assert.equal(result.deploymentAttrs.runtime, undefined);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('makeFileDeploymentMetadata handles invalid package.json gracefully', async () => {
|
|
116
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
117
|
+
fs: fsMock,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const folderPath = '/absolute/path/to/bad-json-app';
|
|
121
|
+
|
|
122
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
123
|
+
isDirectory: () => true,
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
fsMock.existsSync.mock.mockImplementation((p) => {
|
|
127
|
+
if (p.endsWith('package.json')) return true;
|
|
128
|
+
return false;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Mock readFileSync to return invalid JSON
|
|
132
|
+
fsMock.readFileSync.mock.mockImplementation(() => '{ invalid json }');
|
|
133
|
+
|
|
134
|
+
const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
|
|
135
|
+
|
|
136
|
+
// Should return empty deployment attrs
|
|
137
|
+
assert.equal(result.deploymentAttrs.runtime, undefined);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('canDeployWithoutBuild returns true for valid Node.js zip deploy', async () => {
|
|
141
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
142
|
+
fs: fsMock,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const metadata = {
|
|
146
|
+
hasDockerfile: false,
|
|
147
|
+
deploymentAttrs: {
|
|
148
|
+
runtime: 'nodejs',
|
|
149
|
+
cmd: ['node'],
|
|
150
|
+
args: ['index.js'],
|
|
151
|
+
baseImage: 'base-image',
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('canDeployWithoutBuild returns false if Dockerfile exists', async () => {
|
|
159
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
160
|
+
fs: fsMock,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const metadata = {
|
|
164
|
+
hasDockerfile: true,
|
|
165
|
+
deploymentAttrs: {
|
|
166
|
+
runtime: 'nodejs',
|
|
167
|
+
cmd: ['node'],
|
|
168
|
+
args: ['index.js'],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('createDirectSourceDeploymentContainer creates correct object', async () => {
|
|
176
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
177
|
+
fs: fsMock,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const input = {
|
|
181
|
+
bucketName: 'test-bucket',
|
|
182
|
+
fileName: 'source.tar.gz',
|
|
183
|
+
deploymentAttrs: {
|
|
184
|
+
cmd: ['node'],
|
|
185
|
+
args: ['server.js'],
|
|
186
|
+
baseImage: 'gcr.io/google-appengine/nodejs',
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result =
|
|
191
|
+
deploymentHelpers.createDirectSourceDeploymentContainer(input);
|
|
192
|
+
|
|
193
|
+
assert.deepEqual(result, {
|
|
194
|
+
image: DEPLOYMENT_CONFIG.NO_BUILD_IMAGE_TYPE,
|
|
195
|
+
baseImageUri: 'gcr.io/google-appengine/nodejs',
|
|
196
|
+
sourceCode: {
|
|
197
|
+
cloudStorageSource: {
|
|
198
|
+
bucket: 'test-bucket',
|
|
199
|
+
object: 'source.tar.gz',
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
command: ['node'],
|
|
203
|
+
args: ['server.js'],
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('makeFileDeploymentMetadata handles invalid package.json gracefully', async () => {
|
|
208
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
209
|
+
fs: fsMock,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const folderPath = '/absolute/path/to/bad-json-app';
|
|
213
|
+
|
|
214
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
215
|
+
isDirectory: () => true,
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
fsMock.existsSync.mock.mockImplementation((p) => {
|
|
219
|
+
if (p.endsWith('package.json')) return true;
|
|
220
|
+
return false;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Mock readFileSync to return invalid JSON
|
|
224
|
+
fsMock.readFileSync.mock.mockImplementation(() => '{ invalid json }');
|
|
225
|
+
|
|
226
|
+
const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
|
|
227
|
+
|
|
228
|
+
// Should return empty deployment attrs
|
|
229
|
+
assert.equal(result.deploymentAttrs.runtime, undefined);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { test, describe, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { waitForString } from './test-utils.js';
|
|
5
|
+
|
|
6
|
+
describe('MCP Server stdio startup', () => {
|
|
7
|
+
let serverProcess;
|
|
8
|
+
let stderr = '';
|
|
9
|
+
const stdioMsg = 'Cloud Run MCP server stdio transport connected';
|
|
10
|
+
|
|
11
|
+
describe('when GCP_STDIO=true', () => {
|
|
12
|
+
before(async () => {
|
|
13
|
+
stderr = '';
|
|
14
|
+
serverProcess = spawn('node', ['mcp-server.js'], {
|
|
15
|
+
cwd: process.cwd(),
|
|
16
|
+
env: { ...process.env, GCP_STDIO: 'true' },
|
|
17
|
+
});
|
|
18
|
+
stderr = await waitForString(serverProcess.stderr, stdioMsg);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
after(() => {
|
|
22
|
+
if (serverProcess) {
|
|
23
|
+
serverProcess.kill();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('should start in stdio mode', () => {
|
|
28
|
+
assert.ok(stderr.includes(stdioMsg));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('when GCP_STDIO=false', () => {
|
|
33
|
+
before(async () => {
|
|
34
|
+
stderr = '';
|
|
35
|
+
const env = { ...process.env };
|
|
36
|
+
env.GCP_STDIO = 'false';
|
|
37
|
+
serverProcess = spawn('node', ['mcp-server.js'], {
|
|
38
|
+
cwd: process.cwd(),
|
|
39
|
+
env: env,
|
|
40
|
+
});
|
|
41
|
+
const stderrChunks = [];
|
|
42
|
+
serverProcess.stderr.on('data', (chunk) => {
|
|
43
|
+
stderrChunks.push(chunk);
|
|
44
|
+
});
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
46
|
+
stderr = Buffer.concat(stderrChunks).toString();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
after(() => {
|
|
50
|
+
if (serverProcess) {
|
|
51
|
+
serverProcess.kill();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('should not start in stdio mode', () => {
|
|
56
|
+
assert.ok(!stderr.includes(stdioMsg));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -3,6 +3,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
|
|
3
3
|
import { test, describe, before, after } from 'node:test';
|
|
4
4
|
import assert from 'node:assert/strict';
|
|
5
5
|
import { spawn } from 'child_process';
|
|
6
|
+
import { waitForString } from './test-utils.js';
|
|
6
7
|
|
|
7
8
|
class MCPClient {
|
|
8
9
|
client = null;
|
|
@@ -29,29 +30,32 @@ class MCPClient {
|
|
|
29
30
|
describe('MCP Server in Streamble HTTP mode', () => {
|
|
30
31
|
let client;
|
|
31
32
|
let serverProcess;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
let stdout = '';
|
|
34
|
+
const httpMsg = 'Cloud Run MCP server listening on port 3000';
|
|
35
|
+
|
|
36
|
+
describe('when GCP_STDIO=false', () => {
|
|
37
|
+
before(async () => {
|
|
38
|
+
stdout = '';
|
|
39
|
+
// Start MCP server as a child process
|
|
40
|
+
serverProcess = spawn('node', ['mcp-server.js'], {
|
|
41
|
+
cwd: process.cwd(),
|
|
42
|
+
env: { ...process.env, GCP_STDIO: 'false' },
|
|
43
|
+
});
|
|
44
|
+
stdout = await waitForString(serverProcess.stdout, httpMsg);
|
|
45
|
+
|
|
46
|
+
client = new MCPClient('http-server');
|
|
39
47
|
});
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
after(async () => {
|
|
48
|
-
await client.cleanup();
|
|
49
|
-
if (serverProcess) {
|
|
50
|
-
serverProcess.kill();
|
|
51
|
-
}
|
|
52
|
-
});
|
|
49
|
+
after(async () => {
|
|
50
|
+
await client.cleanup();
|
|
51
|
+
if (serverProcess) {
|
|
52
|
+
serverProcess.kill();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
test('should start an HTTP server', async () => {
|
|
57
|
+
await client.connectToServer('http://localhost:3000/mcp');
|
|
58
|
+
assert.ok(stdout.includes(httpMsg));
|
|
59
|
+
});
|
|
56
60
|
});
|
|
57
61
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mock } from 'node:test';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a mock Cloud Build Client.
|
|
5
|
+
* @param {object} [overrides] - Optional overrides for methods.
|
|
6
|
+
* @returns {object} A mock Cloud Build Client.
|
|
7
|
+
*/
|
|
8
|
+
export function getMockCloudBuildClient(overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
createBuild:
|
|
11
|
+
overrides.createBuild ||
|
|
12
|
+
mock.fn(() =>
|
|
13
|
+
Promise.resolve([
|
|
14
|
+
{
|
|
15
|
+
metadata: {
|
|
16
|
+
build: {
|
|
17
|
+
id: 'default-mock-build-id',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
])
|
|
22
|
+
),
|
|
23
|
+
getBuild:
|
|
24
|
+
overrides.getBuild ||
|
|
25
|
+
mock.fn(() =>
|
|
26
|
+
Promise.resolve([
|
|
27
|
+
{
|
|
28
|
+
id: 'default-mock-build-id',
|
|
29
|
+
status: 'SUCCESS',
|
|
30
|
+
},
|
|
31
|
+
])
|
|
32
|
+
),
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2025 Google LLC
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { test, describe, mock } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import esmock from 'esmock';
|
|
20
|
+
|
|
21
|
+
describe('Source Processor', () => {
|
|
22
|
+
const fspMock = {
|
|
23
|
+
mkdir: mock.fn(),
|
|
24
|
+
stat: mock.fn(),
|
|
25
|
+
copyFile: mock.fn(),
|
|
26
|
+
rm: mock.fn(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const childProcessMock = {
|
|
30
|
+
exec: mock.fn(),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
test('prepareSourceDirectory creates temp dir and copies files', async () => {
|
|
34
|
+
const sourceProcessor = await esmock(
|
|
35
|
+
'../../lib/deployment/source-processor.js',
|
|
36
|
+
{
|
|
37
|
+
'node:fs/promises': fspMock,
|
|
38
|
+
'node:child_process': childProcessMock,
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const files = ['/path/to/source/file1.js', '/path/to/source/dir1'];
|
|
43
|
+
|
|
44
|
+
fspMock.stat.mock.mockImplementation(async (filePath) => {
|
|
45
|
+
if (filePath.endsWith('dir1')) return { isDirectory: () => true };
|
|
46
|
+
return { isDirectory: () => false };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Mock exec for directory copy (cp -R)
|
|
50
|
+
childProcessMock.exec.mock.mockImplementation((cmd, callback) => {
|
|
51
|
+
// Handle sync callback for util.promisify
|
|
52
|
+
if (callback) callback(null, 'ok', '');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const progressCallback = mock.fn();
|
|
56
|
+
|
|
57
|
+
const tempDir = await sourceProcessor.prepareSourceDirectory(
|
|
58
|
+
files,
|
|
59
|
+
progressCallback
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
assert.ok(
|
|
63
|
+
tempDir.includes('file1.js-'),
|
|
64
|
+
'Temp dir should have expected prefix (based on first filename)'
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
assert.equal(fspMock.mkdir.mock.callCount(), 1);
|
|
68
|
+
assert.equal(fspMock.copyFile.mock.callCount(), 1); // for file1.js
|
|
69
|
+
|
|
70
|
+
// Check if cp -R was called for dir1
|
|
71
|
+
const execCalls = childProcessMock.exec.mock.calls;
|
|
72
|
+
const cpCall = execCalls.find((call) =>
|
|
73
|
+
call.arguments[0].startsWith('cp -R')
|
|
74
|
+
);
|
|
75
|
+
assert.ok(cpCall, 'Should call cp -R for directories');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('installDependencies runs npm install for Node.js', async () => {
|
|
79
|
+
// We need to mock fs.existsSync as well
|
|
80
|
+
const fsMock = {
|
|
81
|
+
existsSync: mock.fn(),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const sourceProcessor = await esmock(
|
|
85
|
+
'../../lib/deployment/source-processor.js',
|
|
86
|
+
{
|
|
87
|
+
'node:fs': fsMock,
|
|
88
|
+
'node:fs/promises': fspMock,
|
|
89
|
+
'node:child_process': childProcessMock,
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const targetDir = '/temp/dir';
|
|
94
|
+
const deploymentAttrs = {
|
|
95
|
+
runtime: 'nodejs',
|
|
96
|
+
cmd: ['node', 'server.js'],
|
|
97
|
+
args: ['server.js'],
|
|
98
|
+
baseImage: 'node:22',
|
|
99
|
+
};
|
|
100
|
+
const progressCallback = mock.fn();
|
|
101
|
+
|
|
102
|
+
// Mock existsSync
|
|
103
|
+
fsMock.existsSync.mock.mockImplementation((p) => {
|
|
104
|
+
if (p.includes('node_modules')) return false; // not installed yet
|
|
105
|
+
if (p.includes('package-lock.json')) return false; // no lock file
|
|
106
|
+
return false;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Mock exec for npm install
|
|
110
|
+
childProcessMock.exec.mock.mockImplementation((cmd, opts, callback) => {
|
|
111
|
+
if (typeof opts === 'function') callback = opts;
|
|
112
|
+
if (callback) callback(null, 'ok', '');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await sourceProcessor.installDependencies(
|
|
116
|
+
targetDir,
|
|
117
|
+
deploymentAttrs,
|
|
118
|
+
progressCallback
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const calls = childProcessMock.exec.mock.calls;
|
|
122
|
+
const npmCall = calls.find((call) =>
|
|
123
|
+
call.arguments[0].includes('npm install')
|
|
124
|
+
);
|
|
125
|
+
assert.ok(npmCall, 'Should run npm install');
|
|
126
|
+
assert.strictEqual(npmCall.arguments[1].cwd, targetDir);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('installDependencies uses npm ci if lockfile exists', async () => {
|
|
130
|
+
const fsMock = {
|
|
131
|
+
existsSync: mock.fn(),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const sourceProcessor = await esmock(
|
|
135
|
+
'../../lib/deployment/source-processor.js',
|
|
136
|
+
{
|
|
137
|
+
'node:fs': fsMock,
|
|
138
|
+
'node:child_process': childProcessMock,
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const targetDir = '/temp/dir';
|
|
143
|
+
const deploymentAttrs = {
|
|
144
|
+
runtime: 'nodejs',
|
|
145
|
+
cmd: ['node', 'server.js'],
|
|
146
|
+
args: ['server.js'],
|
|
147
|
+
baseImage: 'node:22',
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Mock existsSync
|
|
151
|
+
fsMock.existsSync.mock.mockImplementation((p) => {
|
|
152
|
+
if (p.includes('node_modules')) return false;
|
|
153
|
+
if (p.includes('package-lock.json')) return true; // has lock file
|
|
154
|
+
return false;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
childProcessMock.exec.mock.mockImplementation((cmd, opts, callback) => {
|
|
158
|
+
if (typeof opts === 'function') callback = opts;
|
|
159
|
+
if (callback) callback(null, 'ok', '');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await sourceProcessor.installDependencies(
|
|
163
|
+
targetDir,
|
|
164
|
+
deploymentAttrs,
|
|
165
|
+
() => {}
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const calls = childProcessMock.exec.mock.calls;
|
|
169
|
+
const npmCall = calls.find((call) => call.arguments[0].includes('npm ci'));
|
|
170
|
+
assert.ok(npmCall, 'Should run npm ci');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('installDependencies skips if node_modules exists', async () => {
|
|
174
|
+
const fsMock = {
|
|
175
|
+
existsSync: mock.fn(),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const sourceProcessor = await esmock(
|
|
179
|
+
'../../lib/deployment/source-processor.js',
|
|
180
|
+
{
|
|
181
|
+
'node:fs': fsMock,
|
|
182
|
+
'node:child_process': childProcessMock,
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
fsMock.existsSync.mock.mockImplementation((p) => {
|
|
187
|
+
if (p.includes('node_modules')) return true;
|
|
188
|
+
return false;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// clear mock calls
|
|
192
|
+
childProcessMock.exec.mock.resetCalls();
|
|
193
|
+
|
|
194
|
+
await sourceProcessor.installDependencies(
|
|
195
|
+
'/dir',
|
|
196
|
+
{
|
|
197
|
+
runtime: 'nodejs',
|
|
198
|
+
cmd: ['node', 'server.js'],
|
|
199
|
+
args: ['server.js'],
|
|
200
|
+
baseImage: 'node:22',
|
|
201
|
+
},
|
|
202
|
+
() => {}
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
assert.equal(childProcessMock.exec.mock.callCount(), 0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('installDependencies logs warn on failure but does not throw', async () => {
|
|
209
|
+
const fsMock = { existsSync: mock.fn(() => false) };
|
|
210
|
+
const sourceProcessor = await esmock(
|
|
211
|
+
'../../lib/deployment/source-processor.js',
|
|
212
|
+
{
|
|
213
|
+
'node:fs': fsMock,
|
|
214
|
+
'node:child_process': childProcessMock,
|
|
215
|
+
}
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
childProcessMock.exec.mock.mockImplementation((cmd, opts, callback) => {
|
|
219
|
+
if (typeof opts === 'function') callback = opts;
|
|
220
|
+
callback(new Error('npm failed'));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const consoleLogMock = mock.method(console, 'log');
|
|
224
|
+
|
|
225
|
+
await assert.doesNotReject(async () => {
|
|
226
|
+
await sourceProcessor.installDependencies('/dir', { runtime: 'nodejs' });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
assert.equal(
|
|
230
|
+
consoleLogMock.mock.callCount(),
|
|
231
|
+
1,
|
|
232
|
+
'console.log should be called once'
|
|
233
|
+
);
|
|
234
|
+
const logArgs = consoleLogMock.mock.calls[0].arguments;
|
|
235
|
+
assert.ok(
|
|
236
|
+
logArgs[0].includes('Dependency installation failed: npm failed'),
|
|
237
|
+
'console.log should contain the error message'
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
consoleLogMock.mock.restore();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('cleanupTempDirectory removes directory', async () => {
|
|
244
|
+
const fsMock = {
|
|
245
|
+
existsSync: mock.fn(() => true),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const sourceProcessor = await esmock(
|
|
249
|
+
'../../lib/deployment/source-processor.js',
|
|
250
|
+
{
|
|
251
|
+
'node:fs': fsMock,
|
|
252
|
+
'node:fs/promises': fspMock,
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
await sourceProcessor.cleanupTempDirectory('/temp/dir');
|
|
257
|
+
|
|
258
|
+
assert.equal(fspMock.rm.mock.callCount(), 1);
|
|
259
|
+
assert.deepEqual(fspMock.rm.mock.calls[0].arguments[0], '/temp/dir');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('cleanupTempDirectory logs error on failure but does not throw', async () => {
|
|
263
|
+
const fsMock = { existsSync: mock.fn(() => true) };
|
|
264
|
+
// mock fsp.rm to throw
|
|
265
|
+
const fspMockThrow = {
|
|
266
|
+
rm: mock.fn(async () => {
|
|
267
|
+
throw new Error('rm failed');
|
|
268
|
+
}),
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const consoleErrorMock = mock.method(console, 'error');
|
|
272
|
+
|
|
273
|
+
const sourceProcessor = await esmock(
|
|
274
|
+
'../../lib/deployment/source-processor.js',
|
|
275
|
+
{
|
|
276
|
+
'node:fs': fsMock,
|
|
277
|
+
'node:fs/promises': fspMockThrow,
|
|
278
|
+
}
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
await sourceProcessor.cleanupTempDirectory('/dir');
|
|
282
|
+
|
|
283
|
+
assert.equal(consoleErrorMock.mock.callCount(), 1);
|
|
284
|
+
assert.ok(
|
|
285
|
+
consoleErrorMock.mock.calls[0].arguments[1].message.includes('rm failed')
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
consoleErrorMock.mock.restore();
|
|
289
|
+
});
|
|
290
|
+
});
|