@nosto/nosto-cli 1.2.0 → 1.2.2

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.
@@ -6,6 +6,7 @@ on:
6
6
 
7
7
  permissions:
8
8
  contents: read
9
+ id-token: write
9
10
 
10
11
  # Allow only one concurrent deployment, and wait for the current one to finish
11
12
  concurrency:
@@ -34,9 +35,10 @@ jobs:
34
35
  run: npm test
35
36
 
36
37
  - name: Publish project
37
- uses: cycjimmy/semantic-release-action@9cc899c47e6841430bbaedb43de1560a568dfd16 # v5.0.0
38
+ uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6.0.0
38
39
  id: semantic
39
40
  with:
41
+ semantic_version: 25.0.2
40
42
  extra_plugins: |
41
43
  @semantic-release/changelog
42
44
  @semantic-release/git
@@ -46,4 +48,3 @@ jobs:
46
48
  ]
47
49
  env:
48
50
  GITHUB_TOKEN: ${{ secrets.RELEASE_PAT_TOKEN }}
49
- NPM_TOKEN: ${{ secrets.NPMJS_TOKEN }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nosto/nosto-cli",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "main": "./src/index.ts",
5
5
  "bin": {
6
6
  "nosto": "./src/bootstrap.mjs"
@@ -62,5 +62,9 @@
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"
65
+ },
66
+ "repository": {
67
+ "type": "git",
68
+ "url": "git+https://github.com/Nosto/nosto-cli"
65
69
  }
66
70
  }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { createDeployment } from "#api/deployments/createDeployment.ts"
4
+ import { setupMockConfig } from "#test/utils/mockConfig.ts"
5
+ import { mockCreateDeployment, setupMockServer } from "#test/utils/mockServer.ts"
6
+
7
+ const server = setupMockServer()
8
+ setupMockConfig()
9
+
10
+ describe("createDeployment", () => {
11
+ it("should create a deployment with description", async () => {
12
+ const mock = mockCreateDeployment(server, { path: "build" })
13
+
14
+ await createDeployment({ path: "build", description: "Test deployment" })
15
+
16
+ expect(mock.invocations).toHaveLength(1)
17
+ expect(mock.invocations[0]).toEqual({ description: "Test deployment" })
18
+ })
19
+
20
+ it("should throw an error when server returns error", async () => {
21
+ mockCreateDeployment(server, { path: "build", error: { status: 500, message: "Server Error" } })
22
+
23
+ await expect(createDeployment({ path: "build", description: "Test deployment" })).rejects.toThrow()
24
+ })
25
+
26
+ it("should handle empty description", async () => {
27
+ const mock = mockCreateDeployment(server, { path: "build" })
28
+
29
+ await createDeployment({ path: "build", description: "" })
30
+
31
+ expect(mock.invocations).toHaveLength(1)
32
+ expect(mock.invocations[0]).toEqual({ description: "" })
33
+ })
34
+
35
+ it("should use correct path in URL", async () => {
36
+ const mock = mockCreateDeployment(server, { path: "custom-path" })
37
+
38
+ await createDeployment({ path: "custom-path", description: "Test deployment" })
39
+
40
+ expect(mock.invocations).toHaveLength(1)
41
+ })
42
+ })
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { listDeployments } from "#api/deployments/listDeployments.ts"
4
+ import { setupMockConfig } from "#test/utils/mockConfig.ts"
5
+ import { createMockDeployment } from "#test/utils/mockDeployment.ts"
6
+ import { mockListDeployments, setupMockServer } from "#test/utils/mockServer.ts"
7
+
8
+ const server = setupMockServer()
9
+ setupMockConfig()
10
+
11
+ const mockDeploymentsWithAllFields = [
12
+ createMockDeployment({
13
+ id: "1763737018",
14
+ created: 1732200000000,
15
+ active: false,
16
+ latest: true,
17
+ userId: "user@nosto.com",
18
+ description: "Latest deployment"
19
+ }),
20
+ createMockDeployment({
21
+ id: "1763737609",
22
+ created: 1732199000000,
23
+ active: true,
24
+ latest: false,
25
+ userId: "user@nosto.com",
26
+ description: "Additional fixes etc.."
27
+ })
28
+ ]
29
+
30
+ const mockDeploymentsWithoutOptionalFields = [
31
+ createMockDeployment({
32
+ id: "1763737018",
33
+ created: 1732200000000,
34
+ active: false,
35
+ latest: true
36
+ })
37
+ ]
38
+
39
+ describe("listDeployments", () => {
40
+ it("should fetch and return list of deployments", async () => {
41
+ mockListDeployments(server, { response: mockDeploymentsWithAllFields })
42
+
43
+ const deployments = await listDeployments()
44
+
45
+ expect(deployments).toEqual(mockDeploymentsWithAllFields)
46
+ expect(deployments).toHaveLength(2)
47
+ })
48
+
49
+ it("should return empty array when no deployments exist", async () => {
50
+ mockListDeployments(server, { response: [] })
51
+
52
+ const deployments = await listDeployments()
53
+
54
+ expect(deployments).toEqual([])
55
+ expect(deployments).toHaveLength(0)
56
+ })
57
+
58
+ it("should handle deployments without optional fields", async () => {
59
+ mockListDeployments(server, { response: mockDeploymentsWithoutOptionalFields })
60
+
61
+ const deployments = await listDeployments()
62
+
63
+ expect(deployments).toEqual(mockDeploymentsWithoutOptionalFields)
64
+ expect(deployments[0].userId).toBeUndefined()
65
+ expect(deployments[0].description).toBeUndefined()
66
+ })
67
+
68
+ it("should throw an error when server returns error", async () => {
69
+ mockListDeployments(server, { error: { status: 500, message: "Server Error" } })
70
+
71
+ await expect(listDeployments()).rejects.toThrow()
72
+ })
73
+
74
+ it("should validate response schema", async () => {
75
+ const invalidDeployments = [
76
+ {
77
+ id: "1763737018",
78
+ // Missing required fields
79
+ active: false
80
+ }
81
+ ]
82
+
83
+ // @ts-expect-error - Testing invalid schema
84
+ mockListDeployments(server, { response: invalidDeployments })
85
+
86
+ await expect(listDeployments()).rejects.toThrow()
87
+ })
88
+ })
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { rollbackDeployment } from "#api/deployments/rollbackDeployment.ts"
4
+ import { setupMockConfig } from "#test/utils/mockConfig.ts"
5
+ import { mockRollbackDeployment, setupMockServer } from "#test/utils/mockServer.ts"
6
+
7
+ const server = setupMockServer()
8
+ setupMockConfig()
9
+
10
+ describe("rollbackDeployment", () => {
11
+ it("should disable active deployment", async () => {
12
+ const mock = mockRollbackDeployment(server, {})
13
+
14
+ await rollbackDeployment()
15
+
16
+ expect(mock.invocations).toHaveLength(1)
17
+ })
18
+
19
+ it("should throw an error when server returns error", async () => {
20
+ mockRollbackDeployment(server, { error: { status: 500, message: "Server Error" } })
21
+
22
+ await expect(rollbackDeployment()).rejects.toThrow()
23
+ })
24
+
25
+ it("should handle 404 when no active deployment exists", async () => {
26
+ mockRollbackDeployment(server, { error: { status: 404, message: "Not Found" } })
27
+
28
+ await expect(rollbackDeployment()).rejects.toThrow()
29
+ })
30
+ })
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { updateDeployment } from "#api/deployments/updateDeployment.ts"
4
+ import { setupMockConfig } from "#test/utils/mockConfig.ts"
5
+ import { mockUpdateDeployment, setupMockServer } from "#test/utils/mockServer.ts"
6
+
7
+ const server = setupMockServer()
8
+ setupMockConfig()
9
+
10
+ describe("updateDeployment", () => {
11
+ it("should redeploy a deployment by ID", async () => {
12
+ const mock = mockUpdateDeployment(server, { deploymentId: "1763737018" })
13
+
14
+ await updateDeployment("1763737018")
15
+
16
+ expect(mock.invocations).toHaveLength(1)
17
+ })
18
+
19
+ it("should throw an error when server returns error", async () => {
20
+ mockUpdateDeployment(server, { deploymentId: "1763737018", error: { status: 500, message: "Server Error" } })
21
+
22
+ await expect(updateDeployment("1763737018")).rejects.toThrow()
23
+ })
24
+
25
+ it("should handle different deployment IDs", async () => {
26
+ const mock = mockUpdateDeployment(server, { deploymentId: "9999999999" })
27
+
28
+ await updateDeployment("9999999999")
29
+
30
+ expect(mock.invocations).toHaveLength(1)
31
+ })
32
+ })
@@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, MockInstance, vi } from "vitest"
2
2
 
3
3
  import { clearCachedConfig, getCachedConfig } from "#config/config.ts"
4
4
  import { MissingConfigurationError } from "#errors/MissingConfigurationError.ts"
5
+ import * as deployModule from "#modules/deployments/deploy.ts"
6
+ import * as listModule from "#modules/deployments/list.ts"
7
+ import * as redeployModule from "#modules/deployments/redeploy.ts"
8
+ import * as rollbackModule from "#modules/deployments/rollback.ts"
5
9
  import * as login from "#modules/login.ts"
6
10
  import * as logout from "#modules/logout.ts"
7
11
  import * as build from "#modules/search-templates/build.ts"
@@ -25,6 +29,10 @@ let buildSpy: MockInstance
25
29
  let pullSpy: MockInstance
26
30
  let pushSpy: MockInstance
27
31
  let devSpy: MockInstance
32
+ let deploymentsListSpy: MockInstance
33
+ let deploymentsDeploySpy: MockInstance
34
+ let deploymentsRedeploySpy: MockInstance
35
+ let deploymentsRollbackSpy: MockInstance
28
36
 
29
37
  describe("commander", () => {
30
38
  beforeEach(() => {
@@ -36,6 +44,10 @@ describe("commander", () => {
36
44
  pullSpy = vi.spyOn(pull, "pullSearchTemplate").mockImplementation(() => Promise.resolve())
37
45
  pushSpy = vi.spyOn(push, "pushSearchTemplate").mockImplementation(() => Promise.resolve())
38
46
  devSpy = vi.spyOn(dev, "searchTemplateDevMode").mockImplementation(() => Promise.resolve())
47
+ deploymentsListSpy = vi.spyOn(listModule, "deploymentsList").mockImplementation(() => Promise.resolve())
48
+ deploymentsDeploySpy = vi.spyOn(deployModule, "deploymentsDeploy").mockImplementation(() => Promise.resolve())
49
+ deploymentsRedeploySpy = vi.spyOn(redeployModule, "deploymentsRedeploy").mockImplementation(() => Promise.resolve())
50
+ deploymentsRollbackSpy = vi.spyOn(rollbackModule, "deploymentsRollback").mockImplementation(() => Promise.resolve())
39
51
  clearCachedConfig()
40
52
  fs.mockUserAuthentication()
41
53
  })
@@ -133,6 +145,134 @@ describe("commander", () => {
133
145
  })
134
146
  })
135
147
 
148
+ describe("nosto deployments list", () => {
149
+ beforeEach(() => {
150
+ fs.writeFile(".nosto.json", JSON.stringify({ apiKey: "123", merchant: "456" }))
151
+ })
152
+
153
+ it("should call the function", async () => {
154
+ await commander.run("nosto dp list")
155
+ expect(deploymentsListSpy).toHaveBeenCalled()
156
+ })
157
+
158
+ it("should work with alias", async () => {
159
+ await commander.run("nosto deployments list")
160
+ expect(deploymentsListSpy).toHaveBeenCalled()
161
+ })
162
+
163
+ it("should rethrow errors", async () => {
164
+ vi.spyOn(listModule, "deploymentsList").mockImplementation(() => {
165
+ throw new Error("Unknown error")
166
+ })
167
+
168
+ await commander.expect("nosto dp list").toThrow()
169
+ })
170
+ })
171
+
172
+ describe("nosto deployments deploy", () => {
173
+ beforeEach(() => {
174
+ fs.writeFile(".nosto.json", JSON.stringify({ apiKey: "123", merchant: "456" }))
175
+ })
176
+
177
+ it("should call the function with default options", async () => {
178
+ await commander.run("nosto dp deploy")
179
+ expect(deploymentsDeploySpy).toHaveBeenCalledWith({
180
+ description: undefined,
181
+ force: false
182
+ })
183
+ })
184
+
185
+ it("should call the function with description", async () => {
186
+ await commander.run("nosto dp deploy -d test-deployment")
187
+ expect(deploymentsDeploySpy).toHaveBeenCalledWith({
188
+ description: "test-deployment",
189
+ force: false
190
+ })
191
+ })
192
+
193
+ it("should call the function with force flag", async () => {
194
+ await commander.run("nosto dp deploy --force")
195
+ expect(deploymentsDeploySpy).toHaveBeenCalledWith({
196
+ description: undefined,
197
+ force: true
198
+ })
199
+ })
200
+
201
+ it("should rethrow errors", async () => {
202
+ vi.spyOn(deployModule, "deploymentsDeploy").mockImplementation(() => {
203
+ throw new Error("Unknown error")
204
+ })
205
+
206
+ await commander.expect("nosto dp deploy").toThrow()
207
+ })
208
+ })
209
+
210
+ describe("nosto deployments redeploy", () => {
211
+ beforeEach(() => {
212
+ fs.writeFile(".nosto.json", JSON.stringify({ apiKey: "123", merchant: "456" }))
213
+ })
214
+
215
+ it("should call the function with default options", async () => {
216
+ await commander.run("nosto dp redeploy")
217
+ expect(deploymentsRedeploySpy).toHaveBeenCalledWith({
218
+ deploymentId: undefined,
219
+ force: false
220
+ })
221
+ })
222
+
223
+ it("should call the function with deployment ID", async () => {
224
+ await commander.run("nosto dp redeploy -i deployment-123")
225
+ expect(deploymentsRedeploySpy).toHaveBeenCalledWith({
226
+ deploymentId: "deployment-123",
227
+ force: false
228
+ })
229
+ })
230
+
231
+ it("should call the function with force flag", async () => {
232
+ await commander.run("nosto dp redeploy --force")
233
+ expect(deploymentsRedeploySpy).toHaveBeenCalledWith({
234
+ deploymentId: undefined,
235
+ force: true
236
+ })
237
+ })
238
+
239
+ it("should rethrow errors", async () => {
240
+ vi.spyOn(redeployModule, "deploymentsRedeploy").mockImplementation(() => {
241
+ throw new Error("Unknown error")
242
+ })
243
+
244
+ await commander.expect("nosto dp redeploy").toThrow()
245
+ })
246
+ })
247
+
248
+ describe("nosto deployments disable", () => {
249
+ beforeEach(() => {
250
+ fs.writeFile(".nosto.json", JSON.stringify({ apiKey: "123", merchant: "456" }))
251
+ })
252
+
253
+ it("should call the function with default options", async () => {
254
+ await commander.run("nosto dp disable")
255
+ expect(deploymentsRollbackSpy).toHaveBeenCalledWith({
256
+ force: false
257
+ })
258
+ })
259
+
260
+ it("should call the function with force flag", async () => {
261
+ await commander.run("nosto dp disable --force")
262
+ expect(deploymentsRollbackSpy).toHaveBeenCalledWith({
263
+ force: true
264
+ })
265
+ })
266
+
267
+ it("should rethrow errors", async () => {
268
+ vi.spyOn(rollbackModule, "deploymentsRollback").mockImplementation(() => {
269
+ throw new Error("Unknown error")
270
+ })
271
+
272
+ await commander.expect("nosto dp disable").toThrow()
273
+ })
274
+ })
275
+
136
276
  describe("nosto search-templates build", () => {
137
277
  it("should fail sanity check", async () => {
138
278
  await commander.run("nosto st build")
@@ -150,6 +290,21 @@ describe("commander", () => {
150
290
  expect(buildSpy).toHaveBeenCalledWith({ watch: false, push: false })
151
291
  })
152
292
 
293
+ it("should call the function with watch flag", async () => {
294
+ await commander.run("nosto st build --watch")
295
+ expect(buildSpy).toHaveBeenCalledWith({ watch: true, push: false })
296
+ })
297
+
298
+ it("should call the function with push flag", async () => {
299
+ await commander.run("nosto st build --push")
300
+ expect(buildSpy).toHaveBeenCalledWith({ watch: false, push: true })
301
+ })
302
+
303
+ it("should call the function with both watch and push flags", async () => {
304
+ await commander.run("nosto st build -w -p")
305
+ expect(buildSpy).toHaveBeenCalledWith({ watch: true, push: true })
306
+ })
307
+
153
308
  it("should rethrow errors", async () => {
154
309
  vi.spyOn(build, "buildSearchTemplate").mockImplementation(() => {
155
310
  throw new Error("Unknown error")
@@ -0,0 +1,200 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ import * as calculateTreeHashModule from "#filesystem/calculateTreeHash.ts"
4
+ import { deploymentsDeploy } from "#modules/deployments/deploy.ts"
5
+ import { setupMockConfig } from "#test/utils/mockConfig.ts"
6
+ import { setupMockConsole } from "#test/utils/mockConsole.ts"
7
+ import { setupMockFileSystem } from "#test/utils/mockFileSystem.ts"
8
+ import { mockCreateDeployment, mockFetchSourceFile, setupMockServer } from "#test/utils/mockServer.ts"
9
+
10
+ const server = setupMockServer()
11
+ const terminal = setupMockConsole()
12
+ const fs = setupMockFileSystem()
13
+ setupMockConfig()
14
+
15
+ describe("deploymentsDeploy", () => {
16
+ beforeEach(() => {
17
+ vi.spyOn(calculateTreeHashModule, "calculateTreeHash").mockReturnValue("abcd1234")
18
+ })
19
+
20
+ it("should deploy when hashes match", async () => {
21
+ mockFetchSourceFile(server, { path: "build/hash", response: "abcd1234", contentType: "raw" })
22
+ fs.writeFile(".nostocache/hash", "abcd1234")
23
+ const mock = mockCreateDeployment(server, { path: "build" })
24
+
25
+ await deploymentsDeploy({ description: "Test deployment", force: true })
26
+
27
+ expect(mock.invocations).toHaveLength(1)
28
+ expect(mock.invocations[0]).toEqual({ description: "Test deployment" })
29
+ expect(terminal.getSpy("success")).toHaveBeenCalledWith("Deployment created successfully!")
30
+ })
31
+
32
+ it("should show error when no remote files exist", async () => {
33
+ mockFetchSourceFile(server, { path: "build/hash", error: { status: 404, message: "Not Found" } })
34
+
35
+ await deploymentsDeploy({ description: "Test deployment", force: true })
36
+
37
+ expect(terminal.getSpy("error")).toHaveBeenCalledWith(
38
+ "No files found in remote. Please run 'st build --push' first to push your build."
39
+ )
40
+ })
41
+
42
+ it("should prompt for description when not provided", async () => {
43
+ mockFetchSourceFile(server, { path: "build/hash", response: "abcd1234", contentType: "raw" })
44
+ fs.writeFile(".nostocache/hash", "abcd1234")
45
+ terminal.setUserResponse("My deployment description")
46
+ const mock = mockCreateDeployment(server, { path: "build" })
47
+
48
+ await deploymentsDeploy({ force: true })
49
+
50
+ expect(mock.invocations).toHaveLength(1)
51
+ expect(mock.invocations[0]).toEqual({ description: "My deployment description" })
52
+ })
53
+
54
+ it("should cancel when empty description provided", async () => {
55
+ mockFetchSourceFile(server, { path: "build/hash", response: "abcd1234", contentType: "raw" })
56
+ fs.writeFile(".nostocache/hash", "abcd1234")
57
+ terminal.setUserResponse("")
58
+ const mock = mockCreateDeployment(server, { path: "build" })
59
+
60
+ await deploymentsDeploy({ force: true })
61
+
62
+ expect(mock.invocations).toHaveLength(0)
63
+ expect(terminal.getSpy("error")).toHaveBeenCalledWith(
64
+ "Description must be alphanumeric and between 1 and 200 characters."
65
+ )
66
+ })
67
+
68
+ it("should warn and prompt when local hash doesn't match remote", async () => {
69
+ mockFetchSourceFile(server, { path: "build/hash", response: "efgh5678", contentType: "raw" })
70
+ fs.writeFile(".nostocache/hash", "efgh5678")
71
+ terminal.setUserResponse("y")
72
+ const mock = mockCreateDeployment(server, { path: "build" })
73
+
74
+ await deploymentsDeploy({ description: "Test deployment", force: false })
75
+
76
+ expect(terminal.getSpy("warn")).toHaveBeenCalledWith("Local files don't match remote.")
77
+ expect(mock.invocations).toHaveLength(1)
78
+ })
79
+
80
+ it("should cancel deployment when user declines hash mismatch", async () => {
81
+ mockFetchSourceFile(server, { path: "build/hash", response: "efgh5678", contentType: "raw" })
82
+ fs.writeFile(".nostocache/hash", "efgh5678")
83
+ terminal.setUserResponse("n")
84
+ const mock = mockCreateDeployment(server, { path: "build" })
85
+
86
+ await deploymentsDeploy({ description: "Test deployment", force: false })
87
+
88
+ expect(mock.invocations).toHaveLength(0)
89
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Deployment cancelled by user.")
90
+ })
91
+
92
+ it("should warn when remote changed since last sync", async () => {
93
+ mockFetchSourceFile(server, { path: "build/hash", response: "abcd1234", contentType: "raw" })
94
+ fs.writeFile(".nostocache/hash", "different_hash")
95
+ terminal.setUserResponse("y")
96
+ const mock = mockCreateDeployment(server, { path: "build" })
97
+
98
+ await deploymentsDeploy({ description: "Test deployment", force: false })
99
+
100
+ expect(terminal.getSpy("warn")).toHaveBeenCalledWith("Remote files have changed since your last sync.")
101
+ expect(mock.invocations).toHaveLength(1)
102
+ })
103
+
104
+ it("should cancel when user declines remote changes prompt", async () => {
105
+ mockFetchSourceFile(server, { path: "build/hash", response: "abcd1234", contentType: "raw" })
106
+ fs.writeFile(".nostocache/hash", "different_hash")
107
+ terminal.setUserResponse("n")
108
+ const mock = mockCreateDeployment(server, { path: "build" })
109
+
110
+ await deploymentsDeploy({ description: "Test deployment", force: false })
111
+
112
+ expect(mock.invocations).toHaveLength(0)
113
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Deployment cancelled by user.")
114
+ })
115
+
116
+ it("should update cache hash after successful deployment", async () => {
117
+ mockFetchSourceFile(server, { path: "build/hash", response: "abcd1234", contentType: "raw" })
118
+ fs.writeFile(".nostocache/hash", "old_hash")
119
+ mockCreateDeployment(server, { path: "build" })
120
+
121
+ await deploymentsDeploy({ description: "Test deployment", force: true })
122
+
123
+ fs.expectFile(".nostocache/hash").toContain("abcd1234")
124
+ })
125
+
126
+ it("should prompt for final confirmation when not forced", async () => {
127
+ mockFetchSourceFile(server, { path: "build/hash", response: "abcd1234", contentType: "raw" })
128
+ fs.writeFile(".nostocache/hash", "abcd1234")
129
+ terminal.setUserResponse("y")
130
+ const mock = mockCreateDeployment(server, { path: "build" })
131
+
132
+ await deploymentsDeploy({ description: "Test deployment", force: false })
133
+
134
+ expect(mock.invocations).toHaveLength(1)
135
+ terminal.expect.user.toHaveBeenPromptedWith(
136
+ 'You are about to create a deployment with description: "Test deployment". Continue? (y/N):'
137
+ )
138
+ })
139
+
140
+ it("should cancel deployment when user declines final confirmation", async () => {
141
+ mockFetchSourceFile(server, { path: "build/hash", response: "abcd1234", contentType: "raw" })
142
+ fs.writeFile(".nostocache/hash", "abcd1234")
143
+ terminal.setUserResponse("n")
144
+ const mock = mockCreateDeployment(server, { path: "build" })
145
+
146
+ await deploymentsDeploy({ description: "Test deployment", force: false })
147
+
148
+ expect(mock.invocations).toHaveLength(0)
149
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Deployment cancelled by user.")
150
+ })
151
+
152
+ it("should reject description with special characters", async () => {
153
+ mockFetchSourceFile(server, { path: "build/hash", response: "abcd1234", contentType: "raw" })
154
+ fs.writeFile(".nostocache/hash", "abcd1234")
155
+ terminal.setUserResponse("test@deployment#123")
156
+ const mock = mockCreateDeployment(server, { path: "build" })
157
+
158
+ await deploymentsDeploy({ force: true })
159
+
160
+ expect(mock.invocations).toHaveLength(0)
161
+ expect(terminal.getSpy("error")).toHaveBeenCalledWith(
162
+ "Description must be alphanumeric and between 1 and 200 characters."
163
+ )
164
+ })
165
+
166
+ it("should handle both hash mismatch and remote changes warnings", async () => {
167
+ mockFetchSourceFile(server, { path: "build/hash", response: "efgh5678", contentType: "raw" })
168
+ fs.writeFile(".nostocache/hash", "different_hash")
169
+ terminal.setUserResponse("y")
170
+ const mock = mockCreateDeployment(server, { path: "build" })
171
+
172
+ await deploymentsDeploy({ description: "Test deployment", force: false })
173
+
174
+ expect(terminal.getSpy("warn")).toHaveBeenCalledWith("Local files don't match remote.")
175
+ expect(terminal.getSpy("warn")).toHaveBeenCalledWith("Remote files have changed since your last sync.")
176
+ expect(mock.invocations).toHaveLength(1)
177
+ })
178
+
179
+ it("should display deployment info messages", async () => {
180
+ mockFetchSourceFile(server, { path: "build/hash", response: "abcd1234", contentType: "raw" })
181
+ fs.writeFile(".nostocache/hash", "abcd1234")
182
+ mockCreateDeployment(server, { path: "build" })
183
+
184
+ await deploymentsDeploy({ description: "Test deployment", force: true })
185
+
186
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Creating deployment from remote 'build' path...")
187
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith(expect.stringContaining('Description: "Test deployment"'))
188
+ })
189
+
190
+ it("should suggest running st push when local hash mismatch", async () => {
191
+ mockFetchSourceFile(server, { path: "build/hash", response: "efgh5678", contentType: "raw" })
192
+ fs.writeFile(".nostocache/hash", "efgh5678")
193
+ terminal.setUserResponse("n")
194
+ mockCreateDeployment(server, { path: "build" })
195
+
196
+ await deploymentsDeploy({ description: "Test deployment", force: false })
197
+
198
+ expect(terminal.getSpy("warn")).toHaveBeenCalledWith(expect.stringContaining("st push"))
199
+ })
200
+ })