1+ // Copyright (c) Microsoft Corporation.
2+ // Licensed under the MIT License.
3+
4+ using Azure . Sdk . Tools . Cli . Helpers ;
5+ using Azure . Sdk . Tools . Cli . Services ;
6+ using Azure . Sdk . Tools . Cli . Services . Languages ;
7+ using Microsoft . Extensions . Logging . Abstractions ;
8+ using Moq ;
9+
10+ namespace Azure . Sdk . Tools . Cli . Tests . Services . Languages
11+ {
12+ [ TestFixture ]
13+ public class TypeScriptTypecheckerTests
14+ {
15+ private Mock < IDockerService > mockDockerService ;
16+ private TypeScriptTypechecker typechecker ;
17+
18+ [ SetUp ]
19+ public void SetUp ( )
20+ {
21+ mockDockerService = new Mock < IDockerService > ( ) ;
22+ typechecker = new TypeScriptTypechecker ( mockDockerService . Object , NullLogger < TypeScriptTypechecker > . Instance ) ;
23+ }
24+
25+ [ TearDown ]
26+ public async Task TearDown ( )
27+ {
28+ if ( typechecker != null )
29+ {
30+ // Setup mock for container removal during disposal
31+ mockDockerService . Setup ( x => x . RemoveContainerAsync (
32+ It . IsAny < string > ( ) , true , It . IsAny < CancellationToken > ( ) ) )
33+ . ReturnsAsync ( new ProcessResult { ExitCode = 0 } ) ;
34+
35+ await typechecker . DisposeAsync ( ) ;
36+ }
37+ }
38+
39+ [ Test ]
40+ public void Constructor_WithNullDockerService_ThrowsArgumentNullException ( )
41+ {
42+ Assert . Throws < ArgumentNullException > ( ( ) =>
43+ new TypeScriptTypechecker ( null ! , NullLogger < TypeScriptTypechecker > . Instance ) ) ;
44+ }
45+
46+ [ Test ]
47+ public void Constructor_WithNullLogger_ThrowsArgumentNullException ( )
48+ {
49+ Assert . Throws < ArgumentNullException > ( ( ) =>
50+ new TypeScriptTypechecker ( mockDockerService . Object , null ! ) ) ;
51+ }
52+
53+ [ Test ]
54+ public void ParseImportedPackages_WithBasicImports_ReturnsExpectedPackages ( )
55+ {
56+ var code = @"
57+ import { Client } from '@azure/storage-blob';
58+ import * as fs from 'fs';
59+ import './relative-file';
60+ import { DefaultAzureCredential } from '@azure/identity';
61+ const path = require('path');
62+ " ;
63+
64+ var excludedPackages = new HashSet < string > ( ) ;
65+ var packages = TypeScriptTypechecker . ParseImportedPackages ( code , excludedPackages ) ;
66+
67+ Assert . That ( packages , Contains . Item ( "@azure/storage-blob" ) ) ;
68+ Assert . That ( packages , Contains . Item ( "@azure/identity" ) ) ;
69+ Assert . That ( packages , Contains . Item ( "fs" ) ) ;
70+ Assert . That ( packages , Contains . Item ( "path" ) ) ;
71+ Assert . That ( packages , Contains . Item ( "typescript" ) ) ;
72+ Assert . That ( packages , Contains . Item ( "@types/node" ) ) ;
73+
74+ // Should not contain relative imports
75+ Assert . That ( packages , Does . Not . Contain ( "./relative-file" ) ) ;
76+ }
77+
78+ [ Test ]
79+ public void ParseImportedPackages_WithScopedPackages_ExtractsCorrectPackageRoot ( )
80+ {
81+ var code = @"
82+ import { Client } from '@azure/storage-blob/types';
83+ import { helper } from '@azure/core-auth/helpers/utils';
84+ import { normalPkg } from 'normal-package/sub/path';
85+ " ;
86+
87+ var excludedPackages = new HashSet < string > ( ) ;
88+ var packages = TypeScriptTypechecker . ParseImportedPackages ( code , excludedPackages ) ;
89+
90+ Assert . That ( packages , Contains . Item ( "@azure/storage-blob" ) ) ;
91+ Assert . That ( packages , Contains . Item ( "@azure/core-auth" ) ) ;
92+ Assert . That ( packages , Contains . Item ( "normal-package" ) ) ;
93+ }
94+
95+ [ Test ]
96+ public void ParseImportedPackages_WithExcludedPackages_FiltersCorrectly ( )
97+ {
98+ var code = @"
99+ import { Client } from '@azure/storage-blob';
100+ import { DefaultAzureCredential } from '@azure/identity';
101+ " ;
102+
103+ var excludedPackages = new HashSet < string > { "@azure/identity" } ;
104+ var packages = TypeScriptTypechecker . ParseImportedPackages ( code , excludedPackages ) ;
105+
106+ Assert . That ( packages , Contains . Item ( "@azure/storage-blob" ) ) ;
107+ Assert . That ( packages , Does . Not . Contain ( "@azure/identity" ) ) ;
108+ }
109+
110+ [ Test ]
111+ public void ParseImportedPackages_WithDynamicImports_ExtractsPackages ( )
112+ {
113+ var code = @"
114+ const module = await import('@azure/storage-blob');
115+ const fs = await import('fs/promises');
116+ " ;
117+
118+ var excludedPackages = new HashSet < string > ( ) ;
119+ var packages = TypeScriptTypechecker . ParseImportedPackages ( code , excludedPackages ) ;
120+
121+ Assert . That ( packages , Contains . Item ( "@azure/storage-blob" ) ) ;
122+ Assert . That ( packages , Contains . Item ( "fs" ) ) ;
123+ }
124+
125+ [ Test ]
126+ public void ParseImportedPackages_WithBarePackageNames_ExtractsCorrectly ( )
127+ {
128+ var code = @"
129+ import fs from 'fs';
130+ import path from 'path';
131+ import crypto from 'crypto';
132+ import { Client } from '@azure/storage-blob';
133+ " ;
134+
135+ var excludedPackages = new HashSet < string > ( ) ;
136+ var packages = TypeScriptTypechecker . ParseImportedPackages ( code , excludedPackages ) ;
137+
138+ // Verify bare package names (Node.js built-ins) are extracted correctly
139+ Assert . That ( packages , Contains . Item ( "fs" ) ) ;
140+ Assert . That ( packages , Contains . Item ( "path" ) ) ;
141+ Assert . That ( packages , Contains . Item ( "crypto" ) ) ;
142+ Assert . That ( packages , Contains . Item ( "@azure/storage-blob" ) ) ;
143+ }
144+
145+ [ Test ]
146+ public async Task TypecheckAsync_WithValidCode_ReturnsSuccess ( )
147+ {
148+ // Arrange
149+ var code = "const message: string = 'Hello, World!';" ;
150+ var parameters = new TypeCheckRequest ( code , null , null ) ;
151+
152+ SetupSuccessfulDockerInteractions ( ) ;
153+
154+ // Act
155+ var result = await typechecker . TypecheckAsync ( parameters , CancellationToken . None ) ;
156+
157+ // Assert
158+ Assert . That ( result . Succeeded , Is . True ) ;
159+ VerifyDockerInteractionSequence ( ) ;
160+ }
161+
162+ [ Test ]
163+ public async Task TypecheckAsync_WithFailedTypeCheck_ReturnsFailure ( )
164+ {
165+ // Arrange
166+ var code = "const message: string = 123; // Type error" ;
167+ var parameters = new TypeCheckRequest ( code , null , null ) ;
168+
169+ SetupFailedTypecheckInteraction ( ) ;
170+
171+ // Act
172+ var result = await typechecker . TypecheckAsync ( parameters , CancellationToken . None ) ;
173+
174+ // Assert
175+ Assert . That ( result . Succeeded , Is . False ) ;
176+ Assert . That ( result . Output , Contains . Substring ( "tsc output:" ) ) ;
177+ }
178+
179+ [ Test ]
180+ public async Task TypecheckAsync_WithClientDist_InstallsClientPackage ( )
181+ {
182+ // Arrange
183+ var code = "const message: string = 'Hello, World!';" ;
184+ var clientDist = "/path/to/client.tgz" ;
185+ var parameters = new TypeCheckRequest ( code , clientDist , null ) ;
186+
187+ SetupSuccessfulDockerInteractions ( ) ;
188+ SetupClientDistInstallation ( clientDist ) ;
189+
190+ // Create a mock temporary file to simulate the client dist file existing
191+ var tempFile = Path . GetTempFileName ( ) ;
192+ try
193+ {
194+ await File . WriteAllTextAsync ( tempFile , "mock client dist content" ) ;
195+
196+ // Update the test to use the actual temp file path
197+ var parametersWithRealFile = new TypeCheckRequest ( code , tempFile , null ) ;
198+
199+ // Act
200+ var result = await typechecker . TypecheckAsync ( parametersWithRealFile , CancellationToken . None ) ;
201+
202+ // Assert
203+ Assert . That ( result . Succeeded , Is . True ) ;
204+ VerifyClientDistInstallation ( tempFile ) ;
205+ }
206+ finally
207+ {
208+ if ( File . Exists ( tempFile ) )
209+ {
210+ File . Delete ( tempFile ) ;
211+ }
212+ }
213+ }
214+
215+ private void SetupSuccessfulDockerInteractions ( )
216+ {
217+ // Container creation and startup
218+ var createResult = new ProcessResult { ExitCode = 0 } ;
219+ createResult . AppendStdout ( "container-id" ) ;
220+ mockDockerService . Setup ( x => x . CreateContainerAsync (
221+ "node:alpine" , It . IsAny < string > ( ) , null , null , It . IsAny < CancellationToken > ( ) ) )
222+ . ReturnsAsync ( createResult ) ;
223+
224+ mockDockerService . Setup ( x => x . StartContainerAsync (
225+ It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) )
226+ . ReturnsAsync ( new ProcessResult { ExitCode = 0 } ) ;
227+
228+ mockDockerService . Setup ( x => x . IsContainerRunningAsync (
229+ It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) )
230+ . ReturnsAsync ( false ) ; // Force container creation
231+
232+ // File operations
233+ mockDockerService . Setup ( x => x . RunCommandInContainerAsync (
234+ It . IsAny < string > ( ) , It . Is < string [ ] > ( args => args . Contains ( "mkdir" ) ) ,
235+ It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) )
236+ . ReturnsAsync ( new ProcessResult { ExitCode = 0 } ) ;
237+
238+ mockDockerService . Setup ( x => x . CopyToContainerAsync (
239+ It . IsAny < string > ( ) , It . IsAny < string > ( ) , It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) )
240+ . ReturnsAsync ( new ProcessResult { ExitCode = 0 } ) ;
241+
242+ // npm install
243+ var npmInstallResult = new ProcessResult { ExitCode = 0 } ;
244+ npmInstallResult . AppendStdout ( "npm install completed" ) ;
245+ mockDockerService . Setup ( x => x . RunCommandInContainerAsync (
246+ It . IsAny < string > ( ) , It . Is < string [ ] > ( args => args . SequenceEqual ( new [ ] { "npm" , "install" } ) ) ,
247+ It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) )
248+ . ReturnsAsync ( npmInstallResult ) ;
249+
250+ // TypeScript compilation
251+ var tscResult = new ProcessResult { ExitCode = 0 } ;
252+ tscResult . AppendStdout ( "Compilation successful" ) ;
253+ mockDockerService . Setup ( x => x . RunCommandInContainerAsync (
254+ It . IsAny < string > ( ) , It . Is < string [ ] > ( args => args . SequenceEqual ( new [ ] { "npm" , "run" , "typecheck" } ) ) ,
255+ It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) )
256+ . ReturnsAsync ( tscResult ) ;
257+
258+ // Cleanup
259+ mockDockerService . Setup ( x => x . RunCommandInContainerAsync (
260+ It . IsAny < string > ( ) , It . Is < string [ ] > ( args => args . Contains ( "rm" ) ) ,
261+ It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) )
262+ . ReturnsAsync ( new ProcessResult { ExitCode = 0 } ) ;
263+ }
264+
265+ private void SetupFailedTypecheckInteraction ( )
266+ {
267+ SetupSuccessfulDockerInteractions ( ) ;
268+
269+ // Override TypeScript compilation to fail
270+ var failedTscResult = new ProcessResult { ExitCode = 1 } ;
271+ failedTscResult . AppendStderr ( "Type error: string is not assignable to number" ) ;
272+ mockDockerService . Setup ( x => x . RunCommandInContainerAsync (
273+ It . IsAny < string > ( ) , It . Is < string [ ] > ( args => args . SequenceEqual ( new [ ] { "npm" , "run" , "typecheck" } ) ) ,
274+ It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) )
275+ . ReturnsAsync ( failedTscResult ) ;
276+ }
277+
278+ private void SetupClientDistInstallation ( string clientDist )
279+ {
280+ // Client dist installation
281+ var installResult = new ProcessResult { ExitCode = 0 } ;
282+ installResult . AppendStdout ( "Client package installed" ) ;
283+ mockDockerService . Setup ( x => x . RunCommandInContainerAsync (
284+ It . IsAny < string > ( ) , It . Is < string [ ] > ( args =>
285+ args . Length >= 4 && args [ 0 ] == "npm" && args [ 1 ] == "install" && args [ 2 ] == "--no-save" ) ,
286+ It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) )
287+ . ReturnsAsync ( installResult ) ;
288+ }
289+
290+ private void VerifyDockerInteractionSequence ( )
291+ {
292+ // Verify container creation
293+ mockDockerService . Verify ( x => x . CreateContainerAsync (
294+ "node:alpine" , It . IsAny < string > ( ) , null , null , It . IsAny < CancellationToken > ( ) ) ,
295+ Times . Once ) ;
296+
297+ // Verify npm install
298+ mockDockerService . Verify ( x => x . RunCommandInContainerAsync (
299+ It . IsAny < string > ( ) , It . Is < string [ ] > ( args => args . SequenceEqual ( new [ ] { "npm" , "install" } ) ) ,
300+ It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) ,
301+ Times . Once ) ;
302+
303+ // Verify TypeScript compilation
304+ mockDockerService . Verify ( x => x . RunCommandInContainerAsync (
305+ It . IsAny < string > ( ) , It . Is < string [ ] > ( args => args . SequenceEqual ( new [ ] { "npm" , "run" , "typecheck" } ) ) ,
306+ It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) ,
307+ Times . Once ) ;
308+
309+ // Verify cleanup
310+ mockDockerService . Verify ( x => x . RunCommandInContainerAsync (
311+ It . IsAny < string > ( ) , It . Is < string [ ] > ( args => args . Contains ( "rm" ) ) ,
312+ It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) ,
313+ Times . Once ) ;
314+ }
315+
316+ private void VerifyClientDistInstallation ( string clientDist )
317+ {
318+ var fileName = Path . GetFileName ( clientDist ) ;
319+
320+ // Verify client dist copy
321+ mockDockerService . Verify ( x => x . CopyToContainerAsync (
322+ It . IsAny < string > ( ) , clientDist , It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) ,
323+ Times . Once ) ;
324+
325+ // Verify client dist installation
326+ mockDockerService . Verify ( x => x . RunCommandInContainerAsync (
327+ It . IsAny < string > ( ) , It . Is < string [ ] > ( args =>
328+ args . Length >= 4 && args [ 0 ] == "npm" && args [ 1 ] == "install" && args [ 2 ] == "--no-save" && args [ 3 ] == fileName ) ,
329+ It . IsAny < string > ( ) , It . IsAny < CancellationToken > ( ) ) ,
330+ Times . Once ) ;
331+ }
332+ }
333+ }
0 commit comments