Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: internal
packages:
- "@typespec/http-specs"
---

Better way to deal with manifests
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: fix
packages:
- "@typespec/spec-coverage-sdk"
- "@typespec/spector"
---

Update to how coverage manifest are managed. The manifest upload each individual one as a single file
1 change: 0 additions & 1 deletion eng/tsp-core/pipelines/jobs/build-for-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ jobs:

- task: AzureCLI@2
displayName: Upload scenario manifest
condition: eq(variables['PUBLISH_PKG_TYPESPEC_HTTP_SPECS'], 'true')
inputs:
workingDirectory: packages/http-specs
azureSubscription: "TypeSpec Storage"
Expand Down
2 changes: 1 addition & 1 deletion packages/http-specs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"validate-scenarios": "tsp-spector validate-scenarios ./specs",
"generate-scenarios-summary": "tsp-spector generate-scenarios-summary ./specs",
"regen-docs": "pnpm generate-scenarios-summary",
"upload-manifest": "tsp-spector upload-manifest ./specs --setName @typespec/http-specs --containerName manifests-typespec --storageAccountName typespec",
"upload-manifest": "tsp-spector upload-manifest ./specs --containerName coverages --storageAccountName typespec --manifestName http-specs",
"upload-coverage": "tsp-spector upload-coverage --generatorName @typespec/http-specs --generatorVersion 0.1.0-alpha.4 --containerName coverages --generatorMode standard --storageAccountName typespec",
"validate-mock-apis": "tsp-spector validate-mock-apis ./specs",
"check-scenario-coverage": "tsp-spector check-coverage ./specs",
Expand Down
4 changes: 3 additions & 1 deletion packages/spec-coverage-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
"dependencies": {
"@azure/identity": "~4.13.0",
"@azure/storage-blob": "~12.30.0",
"@types/node": "~25.0.2"
"@types/node": "~25.0.2",
"@types/semver": "^7.5.8",
"semver": "^7.7.1"
},
"devDependencies": {
"rimraf": "~6.1.2",
Expand Down
86 changes: 77 additions & 9 deletions packages/spec-coverage-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ContainerClient,
StorageSharedKeyCredential,
} from "@azure/storage-blob";
import { eq as semverEq, gt as semverGt, valid as semverValid } from "semver";
import {
CoverageReport,
GeneratorMetadata,
Expand Down Expand Up @@ -42,26 +43,82 @@ export class SpecCoverageClient {
}

export class SpecManifestOperations {
#blob: BlockBlobClient;
#container: ContainerClient;

constructor(container: ContainerClient) {
this.#container = container;
this.#blob = this.#container.getBlockBlobClient("manifest.json");
}

public async upload(manifest: ScenarioManifest | ScenarioManifest[]): Promise<void> {
public async upload(name: string, manifest: ScenarioManifest): Promise<void> {
await this.#upload(name, "latest", manifest);
await this.#upload(name, manifest.version, manifest);
}

async #upload(name: string, version: string, manifest: ScenarioManifest): Promise<void> {
const blob = this.#container.getBlockBlobClient(this.#blobName(name, version));
const content = JSON.stringify(manifest, null, 2);
await this.#blob.upload(content, content.length, {
await blob.upload(content, content.length, {
blobHTTPHeaders: {
blobContentType: "application/json; charset=utf-8",
},
});
}

public async get(): Promise<ScenarioManifest[]> {
return readJsonBlob<ScenarioManifest[]>(this.#blob);
public async uploadIfVersionNew(
name: string,
manifest: ScenarioManifest,
): Promise<"uploaded" | "skipped"> {
const existingVersion = await this.tryGet(name, manifest.version);
if (existingVersion) {
return "skipped";
}
const existingLatest = await this.tryGet(name);
if (existingLatest && !isVersionNewer(manifest.version, existingLatest.version)) {
return "skipped";
}

await this.upload(name, manifest);
return "uploaded";
}

public async get(name: string, version?: string): Promise<ScenarioManifest> {
const blob = this.#container.getBlockBlobClient(this.#blobName(name, version));
return readJsonBlob<ScenarioManifest>(blob);
}

public async tryGet(name: string, version?: string): Promise<ScenarioManifest | undefined> {
const blob = this.#container.getBlockBlobClient(this.#blobName(name, version));
try {
return await readJsonBlob<ScenarioManifest>(blob);
} catch (e: any) {
if ("code" in e && e.code === "BlobNotFound") {
return undefined;
}
throw e;
}
}

#blobName(name: string, version?: string) {
return `manifests/${name}/${version ?? "latest"}.json`;
}
}

function areVersionsEquivalent(left: string, right: string): boolean {
const leftValid = semverValid(left);
const rightValid = semverValid(right);
if (leftValid && rightValid) {
return semverEq(leftValid, rightValid);
}
return left === right;
}

function isVersionNewer(candidate: string, existing: string): boolean {
const candidateValid = semverValid(candidate);
const existingValid = semverValid(existing);
if (candidateValid && existingValid) {
return semverGt(candidateValid, existingValid);
}
return !areVersionsEquivalent(candidate, existing);
}

export class SpecCoverageOperations {
Expand Down Expand Up @@ -168,7 +225,18 @@ function getCoverageContainer(

async function readJsonBlob<T>(blobClient: BlockBlobClient): Promise<T> {
const blob = await blobClient.download();
const body = await blob.blobBody;
const content = await body!.text();
return JSON.parse(content);
if (blob.blobBody) {
const body = await blob.blobBody;
const content = await body!.text();
return JSON.parse(content);
} else if (blob.readableStreamBody) {
const stream = blob.readableStreamBody;
let content = "";
for await (const chunk of stream) {
content += chunk;
}
return JSON.parse(content);
} else {
throw new Error("Blob has no body");
}
}
17 changes: 3 additions & 14 deletions packages/spec-dashboard/src/apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ export interface TableDefinition {
export interface CoverageFromAzureStorageOptions {
readonly storageAccountName: string;
readonly containerName: string;
// TODO: why was this not back in the same place as the other options?
readonly manifestContainerName: string;
/** Name of the manifests(As located under manifests/<name>.json) for this dashboard */
readonly manifests: string[];
readonly emitterNames: string[];
readonly modes?: string[];
/** Optional table definitions to split scenarios into multiple tables */
Expand Down Expand Up @@ -51,16 +51,6 @@ export function getCoverageClient(options: CoverageFromAzureStorageOptions) {
return client;
}

let manifestClient: SpecCoverageClient | undefined;
export function getManifestClient(options: CoverageFromAzureStorageOptions) {
if (manifestClient === undefined) {
manifestClient = new SpecCoverageClient(options.storageAccountName, {
containerName: options.manifestContainerName,
});
}
return manifestClient;
}

/**
* Checks if a scenario name matches any of the given prefixes
*/
Expand Down Expand Up @@ -161,10 +151,9 @@ export async function getCoverageSummaries(
options: CoverageFromAzureStorageOptions,
): Promise<CoverageSummary[]> {
const coverageClient = getCoverageClient(options);
const manifestClient = getManifestClient(options);

// First, split manifests to determine which emitters we need
const manifests = await manifestClient.manifest.get();
const manifests = await Promise.all(options.manifests.map((x) => coverageClient.manifest.get(x)));
const allManifests: Array<{
manifest: ScenarioManifest;
tableName: string;
Expand Down
45 changes: 29 additions & 16 deletions packages/spector/src/actions/upload-scenario-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,48 @@ import { computeScenarioManifest } from "../coverage/scenario-manifest.js";
import { logger } from "../logger.js";

export interface UploadScenarioManifestConfig {
scenariosPaths: string[];
scenariosPath: string;
storageAccountName: string;
containerName: string;
manifestName: string;
override?: boolean;
}

export async function uploadScenarioManifest({
scenariosPaths,
scenariosPath,
storageAccountName,
containerName,
manifestName,
override = false,
}: UploadScenarioManifestConfig) {
const manifests = [];
for (let idx = 0; idx < scenariosPaths.length; idx++) {
const path = resolve(process.cwd(), scenariosPaths[idx]);
logger.info(`Computing scenario manifest for ${path}`);
const [manifest, diagnostics] = await computeScenarioManifest(path);
if (manifest === undefined || diagnostics.length > 0) {
process.exit(-1);
}
manifests.push(manifest);
const path = resolve(process.cwd(), scenariosPath);
logger.info(`Computing scenario manifest for ${path}`);
const [manifest, diagnostics] = await computeScenarioManifest(path);
if (manifest === undefined || diagnostics.length > 0) {
process.exit(-1);
}
await writeFile("manifest.json", JSON.stringify(manifests, null, 2));
await writeFile("manifest.json", JSON.stringify(manifest, null, 2));
const client = new SpecCoverageClient(storageAccountName, {
credential: new AzureCliCredential(),
containerName,
});
await client.createIfNotExists();
await client.manifest.upload(manifests);
if (override) {
await client.manifest.upload(manifestName, manifest);
logger.info(
`${pc.green("✓")} Scenario manifest uploaded to ${storageAccountName} storage account.`,
);
} else {
const result = await client.manifest.uploadIfVersionNew(manifestName, manifest);

logger.info(
`${pc.green("✓")} Scenario manifest uploaded to ${storageAccountName} storage account.`,
);
if (result === "uploaded") {
logger.info(
`${pc.green("✓")} Scenario manifest new version uploaded to ${storageAccountName} storage account.`,
);
} else {
logger.info(
`${pc.white("-")} Existing scenario manifest in ${storageAccountName} storage account is up to date. No upload needed.`,
);
}
}
}
26 changes: 16 additions & 10 deletions packages/spector/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,20 +273,13 @@ async function main() {
},
)
.command(
"upload-manifest <scenariosPaths..>",
"upload-manifest <scenariosPath>",
"Upload the scenario manifest. DO NOT CALL in generator.",
(cmd) => {
return cmd
.positional("scenariosPaths", {
.positional("scenariosPath", {
description: "Path to the scenarios and mock apis",
type: "string",
array: true,
demandOption: true,
})
.option("setName", {
type: "string",
description: "Set used to generate the manifest.",
array: true,
demandOption: true,
})
.option("storageAccountName", {
Expand All @@ -298,13 +291,26 @@ async function main() {
description: "Name of the Container",
demandOption: true,
})
.option("manifestName", {
type: "string",
description:
"Name of the manifest(will be located at manifests/<manifestName>.json in the container).",
demandOption: true,
})
.option("override", {
type: "boolean",
description: "Override existing manifest with the same version.",
default: false,
})
.demandOption("storageAccountName");
},
async (args) => {
await uploadScenarioManifest({
scenariosPaths: args.scenariosPaths,
scenariosPath: args.scenariosPath,
storageAccountName: args.storageAccountName,
containerName: args.containerName,
manifestName: args.manifestName,
override: args.override,
});
},
)
Expand Down
5 changes: 4 additions & 1 deletion packages/spector/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
"outDir": "dist",
"tsBuildInfoFile": "temp/.tsbuildinfo"
},
"references": [],
"references": [
{ "path": "../spec-api/tsconfig.build.json" },
{ "path": "../spec-coverage-sdk/tsconfig.build.json" }
],
"include": ["src/**/*.ts", "generated-defs/**/*.ts"],
"exclude": ["**/*.test.*"]
}
15 changes: 12 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tsconfig.ws.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
{ "path": "packages/openapi3/tsconfig.build.json" },
{ "path": "packages/spec-api/tsconfig.build.json" },
{ "path": "packages/spector/tsconfig.build.json" },
{ "path": "packages/spec-coverage-sdk/tsconfig.build.json" },
{ "path": "packages/http-specs/tsconfig.build.json" },
{ "path": "packages/monarch/tsconfig.build.json" },
{ "path": "packages/bundler/tsconfig.build.json" },
Expand Down
Loading
Loading