Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0788b52
Initial plan
Copilot Mar 3, 2026
510a174
feat: implement test generation for modular SDK clients
Copilot Mar 3, 2026
f271277
fix: run pnpm format and smoke-test to update generated code
Copilot Mar 3, 2026
f513619
fix: make test helpers loading conditional to fix integration test fa…
Copilot Mar 3, 2026
4c2dd71
fix: run prettier to fix formatting in load-static-helpers.ts
Copilot Mar 3, 2026
6bc766d
Merge branch 'main' into copilot/fix-test-generation-conflicts
MaryGao Mar 3, 2026
42d1606
Update packages/typespec-ts/test/modularUnit/scenarios/test/operation…
v-jiaodi Mar 3, 2026
cd22235
Update packages/typespec-ts/test/modularUnit/scenarios/test/operation…
v-jiaodi Mar 3, 2026
34c79d6
Update packages/typespec-ts/src/framework/hooks/binder.ts
v-jiaodi Mar 3, 2026
8a493fe
Update packages/typespec-ts/src/index.ts
v-jiaodi Mar 3, 2026
3248a5f
Update packages/typespec-ts/src/modular/helpers/exampleValueHelpers.ts
v-jiaodi Mar 3, 2026
802bb10
fix: compute relative src/index.js import path dynamically in emitTes…
Copilot Mar 3, 2026
c2d67f8
chore: run pnpm smoke-test to regenerate platform-specific browser he…
Copilot Mar 3, 2026
626ad3d
revert: remove platform-specific browser helper variant logic from bi…
Copilot Mar 4, 2026
2f33bdc
fix and update case
v-jiaodi Mar 4, 2026
bfc2466
Merge remote-tracking branch 'origin/main' into copilot/fix-test-gene…
Mar 10, 2026
1c17a34
Fix the build issues
Mar 10, 2026
601c062
Remove the azure package condition since this is un-necessary
Mar 10, 2026
84a1e14
Add unit test for test value assertion
Mar 10, 2026
f7a7636
Fix the browser file missing issue
Mar 10, 2026
275453c
Fxi wrong import issue
Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions packages/typespec-ts/src/framework/hooks/binder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export interface Binder {
sourceFile: SourceFile
): string;
resolveReference(refkey: unknown): string;
resolveAllReferences(sourceRoot: string): void;
resolveAllReferences(sourceRoot: string, testRoot?: string): void;
}

const PLACEHOLDER_PREFIX = "__PLACEHOLDER_";
Expand Down Expand Up @@ -216,7 +216,7 @@ class BinderImp implements Binder {
/**
* Applies all tracked imports to their respective source files.
*/
resolveAllReferences(sourceRoot: string): void {
resolveAllReferences(sourceRoot: string, testRoot?: string): void {
this.project.getSourceFiles().map((file) => {
this.resolveDeclarationReferences(file);
this.resolveDependencyReferences(file);
Expand All @@ -232,7 +232,7 @@ class BinderImp implements Binder {
}
});

this.cleanUnreferencedHelpers(sourceRoot);
this.cleanUnreferencedHelpers(sourceRoot, testRoot);
}

private resolveDependencyReferences(file: SourceFile) {
Expand Down Expand Up @@ -292,7 +292,7 @@ class BinderImp implements Binder {
this.references.get(refkey)!.add(sourceFile);
}

private cleanUnreferencedHelpers(sourceRoot: string) {
private cleanUnreferencedHelpers(sourceRoot: string, testRoot?: string) {
const usedHelperNames: string[] = [];
for (const helper of this.staticHelpers.values()) {
const sourceFile = helper[SourceFileSymbol];
Expand All @@ -311,7 +311,6 @@ class BinderImp implements Binder {

Comment thread
MaryGao marked this conversation as resolved.
function isFileUnused(file: SourceFile) {
const name = file.getBaseNameWithoutExtension();

// If one of the used helpers' name is a prefix of this file, the file likely represents a platform-specific implementation of the helper
// so it should be marked as used even if the file has no direct references.
return !usedHelperNames.some((s) => name.startsWith(s));
Expand All @@ -324,6 +323,17 @@ class BinderImp implements Binder {
)
.filter(isFileUnused)
.forEach((helperFile) => helperFile.delete());

if (!testRoot) {
return;
}
this.project
//normalizae the final path to adapt to different systems
.getSourceFiles(
normalizePath(path.join(testRoot, "test/generated/util/**/*.*ts"))
)
.filter(isFileUnused)
.forEach((helperFile) => helperFile.delete());
}
}

Expand Down
130 changes: 79 additions & 51 deletions packages/typespec-ts/src/framework/load-static-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,77 +37,107 @@ export function isStaticHelperMetadata(

export type StaticHelpers = Record<string, StaticHelperMetadata>;

const DEFAULT_STATIC_HELPERS_PATH = "static/static-helpers";
const DEFAULT_SOURCES_STATIC_HELPERS_PATH = "static/static-helpers";
const DEFAULT_SOURCES_TESTING_HELPERS_PATH = "static/test-helpers";

export interface LoadStaticHelpersOptions extends Partial<ModularEmitterOptions> {
helpersAssetDirectory?: string;
sourcesDir?: string;
rootDir?: string;
program?: Program;
/** When true, also load test helpers from static/test-helpers/ into test/generated/util/ */
loadTestHelpers?: boolean;
}

interface FileMetadata {
source: string;
target: string;
}

export async function loadStaticHelpers(
project: Project,
helpers: StaticHelpers,
options: LoadStaticHelpersOptions = {}
): Promise<Map<string, StaticHelperMetadata>> {
const sourcesDir = options.sourcesDir ?? "";
const helpersMap = new Map<string, StaticHelperMetadata>();
// Load static helpers used in sources code
const defaultStaticHelpersPath = path.join(
resolveProjectRoot(),
DEFAULT_STATIC_HELPERS_PATH
DEFAULT_SOURCES_STATIC_HELPERS_PATH
);
const files = await traverseDirectory(
const filesInSources = await traverseDirectory(
options.helpersAssetDirectory ?? defaultStaticHelpersPath,
options.program
);
await loadFiles(filesInSources, options.sourcesDir ?? "");
// Load static helpers used in testing code (only when loadTestHelpers is enabled)
if (
options.loadTestHelpers ??
(options.options?.generateTest &&
isAzurePackage({ options: options.options }))
) {
const defaultTestingHelpersPath = path.join(
resolveProjectRoot(),
DEFAULT_SOURCES_TESTING_HELPERS_PATH
);
const filesInTestings = await traverseDirectory(
defaultTestingHelpersPath,
options.program,
[],
"",
"test/generated/util"
);
await loadFiles(filesInTestings, options.rootDir ?? "");
}
return assertAllHelpersLoadedPresent(helpersMap);

for (const file of files) {
const targetPath = path.join(sourcesDir, file.target);
const contents = await readFile(file.source, "utf-8");
const addedFile = project.createSourceFile(targetPath, contents, {
overwrite: true
});
addedFile.getImportDeclarations().map((i) => {
if (!isAzurePackage({ options: options.options })) {
if (
i
.getModuleSpecifier()
.getFullText()
.includes("@azure/core-rest-pipeline")
) {
i.setModuleSpecifier("@typespec/ts-http-runtime");
async function loadFiles(files: FileMetadata[], generateDir: string) {
for (const file of files) {
const targetPath = path.join(generateDir, file.target);
const contents = await readFile(file.source, "utf-8");
const addedFile = project.createSourceFile(targetPath, contents, {
overwrite: true
});
addedFile.getImportDeclarations().map((i) => {
if (!isAzurePackage({ options: options.options })) {
if (
i
.getModuleSpecifier()
.getFullText()
.includes("@azure/core-rest-pipeline")
) {
i.setModuleSpecifier("@typespec/ts-http-runtime");
}
if (
i
.getModuleSpecifier()
.getFullText()
.includes("@azure-rest/core-client")
) {
i.setModuleSpecifier("@typespec/ts-http-runtime");
}
}
if (
i
.getModuleSpecifier()
.getFullText()
.includes("@azure-rest/core-client")
) {
i.setModuleSpecifier("@typespec/ts-http-runtime");
});

for (const entry of Object.values(helpers)) {
if (!addedFile.getFilePath().endsWith(entry.location)) {
continue;
}
}
});

for (const entry of Object.values(helpers)) {
if (!addedFile.getFilePath().endsWith(entry.location)) {
continue;
}
const declaration = getDeclarationByMetadata(addedFile, entry);
if (!declaration) {
throw new Error(
`Declaration ${
entry.name
} not found in file ${addedFile.getFilePath()}\n This is an Emitter bug, make sure that the map of static helpers passed to loadStaticHelpers matches what is in the file.`
);
}

const declaration = getDeclarationByMetadata(addedFile, entry);
if (!declaration) {
throw new Error(
`Declaration ${
entry.name
} not found in file ${addedFile.getFilePath()}\n This is an Emitter bug, make sure that the map of static helpers passed to loadStaticHelpers matches what is in the file.`
);
entry[SourceFileSymbol] = addedFile;
helpersMap.set(refkey(entry), entry);
}

entry[SourceFileSymbol] = addedFile;
helpersMap.set(refkey(entry), entry);
}
}

return assertAllHelpersLoadedPresent(helpersMap);
}

function assertAllHelpersLoadedPresent(
Expand Down Expand Up @@ -166,7 +196,8 @@ async function traverseDirectory(
directory: string,
program?: Program,
result: { source: string; target: string }[] = [],
relativePath: string = ""
relativePath: string = "",
targetBaseDir: string = _targetStaticHelpersBaseDir
): Promise<{ source: string; target: string }[]> {
try {
const files = await readdir(directory);
Expand All @@ -181,18 +212,15 @@ async function traverseDirectory(
filePath,
program,
result,
path.join(relativePath, file)
path.join(relativePath, file),
targetBaseDir
);
} else if (
fileStat.isFile() &&
!file.endsWith(".d.ts") &&
/.*\..?ts$/.test(file)
) {
const target = path.join(
_targetStaticHelpersBaseDir,
relativePath,
file
);
const target = path.join(targetBaseDir, relativePath, file);
result.push({ source: filePath, target });
}
})
Expand Down
26 changes: 20 additions & 6 deletions packages/typespec-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import {
AzureCoreDependencies,
AzureIdentityDependencies,
AzurePollingDependencies,
DefaultCoreDependencies
DefaultCoreDependencies,
AzureTestDependencies
} from "./modular/external-dependencies.js";
import { clearDirectory } from "./utils/fileSystemUtils.js";
import { EmitContext, Program } from "@typespec/compiler";
import { GenerationDirDetail, SdkContext } from "./utils/interfaces.js";
import {
CloudSettingHelpers,
CreateRecorderHelpers,
MultipartHelpers,
PagingHelpers,
PollingHelpers,
Expand Down Expand Up @@ -107,6 +109,7 @@ import { provideSdkTypes } from "./framework/hooks/sdkTypes.js";
import { transformRLCModel } from "./transform/transform.js";
import { transformRLCOptions } from "./transform/transfromRLCOptions.js";
import { emitSamples } from "./modular/emitSamples.js";
import { emitTests } from "./modular/emitTests.js";
import { generateCrossLanguageDefinitionFile } from "./utils/crossLanguageDef.js";
import { getClassicalClientName } from "./modular/helpers/namingHelpers.js";

Expand Down Expand Up @@ -148,10 +151,12 @@ export async function $onEmit(context: EmitContext) {
...MultipartHelpers,
...CloudSettingHelpers,
...XmlHelpers,
...(rlcOptions.generateTest ? CreateRecorderHelpers : {}),
...(rlcOptions.enableStorageCompat ? StorageCompatHelpers : {})
},
{
sourcesDir: dpgContext.generationPathDetail?.modularSourcesDir,
rootDir: dpgContext.generationPathDetail?.rootDir,
options: rlcOptions,
program
}
Expand All @@ -160,7 +165,8 @@ export async function $onEmit(context: EmitContext) {
? {
...AzurePollingDependencies,
...AzureCoreDependencies,
...AzureIdentityDependencies
...AzureIdentityDependencies,
...AzureTestDependencies
}
: { ...DefaultCoreDependencies };
const binder = provideBinder(outputProject, {
Expand Down Expand Up @@ -382,7 +388,15 @@ export async function $onEmit(context: EmitContext) {
}
}

binder.resolveAllReferences(modularSourcesRoot);
// Enable modular test generation when generateTest is true
if (dpgContext.rlcOptions?.generateTest === true) {
await emitTests(dpgContext);
}

binder.resolveAllReferences(
modularSourcesRoot,
dpgContext.generationPathDetail?.rootDir
);
if (program.compilerOptions.noEmit || program.hasError()) {
return;
}
Expand Down Expand Up @@ -454,7 +468,7 @@ export async function $onEmit(context: EmitContext) {
"test"
);
const hasTestFolder = await existsSync(existingTestFolderPath);
if (option.azureSdkForJs && option.generateTest === undefined) {
if (option.generateTest === undefined) {
if (hasTestFolder) {
option.generateTest = false;
} else {
Expand Down Expand Up @@ -536,7 +550,7 @@ export async function $onEmit(context: EmitContext) {
}

// TODO: need support snippets generation for multi-client cases. https://github.com/Azure/autorest.typescript/issues/3048
if (option.generateTest && isAzureFlavor) {
if (option.generateTest) {
for (const subClient of dpgContext.sdkPackage.clients) {
commonBuilders.push((model) =>
buildSnippets(model, subClient.name, option.azureSdkForJs)
Expand Down Expand Up @@ -633,7 +647,7 @@ export async function $onEmit(context: EmitContext) {
}

// Generate test relevant files
if (option.generateTest && isAzureFlavor && !hasTestFolder) {
if (option.generateTest && !hasTestFolder) {
await emitContentByBuilder(
program,
[buildRecordedClientFile, buildSampleTest],
Expand Down
Loading
Loading