diff --git a/.gitignore b/.gitignore index 19f827f1..2eb10f37 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ external-spec/ test-results/ *.tsbuildinfo .claw/ +csharp-sdk/**/obj/ +csharp-sdk/**/bin/ +api-test-generator.sln diff --git a/configs/camunda-oca/codegen/emitters.json b/configs/camunda-oca/codegen/emitters.json index 02e3830a..394fecf7 100644 --- a/configs/camunda-oca/codegen/emitters.json +++ b/configs/camunda-oca/codegen/emitters.json @@ -1,3 +1,3 @@ { - "emitters": ["playwright"] + "emitters": ["playwright", "js-sdk", "python-sdk", "csharp-sdk"] } diff --git a/configs/camunda-oca/regression-invariants.test.ts b/configs/camunda-oca/regression-invariants.test.ts index 8e76f751..2c98bf4e 100644 --- a/configs/camunda-oca/regression-invariants.test.ts +++ b/configs/camunda-oca/regression-invariants.test.ts @@ -8861,3 +8861,215 @@ describe.skipIf(CONFIG_NAME !== ACTIVE_CONFIG)( }); }, ); +describeForThisConfig('bundled-spec invariants: emitted Python SDK suite (#133)', () => { + it('every URL placeholder in Python SDK suite is either seeded or extracted (mirrors Bug A)', () => { + // Mirrors the Playwright invariant: all ctx reads must resolve to bound + // values at test time. Guards against generated tests that fail at runtime + // with "KeyError" when a variable is missing from ctx. + if (!existsSync(GENERATED_TESTS_DIR)) { + throw new Error( + `Generated tests directory not found at ${GENERATED_TESTS_DIR}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`, + ); + } + const files = readdirSync(GENERATED_TESTS_DIR).filter((f) => f.endsWith('.python_sdk.spec.py')); + if (files.length === 0) { + // No Python SDK tests generated yet; skip + return; + } + + const offenders: Array<{ file: string; placeholder: string; reason: string }> = []; + let assertionsRun = 0; + for (const file of files) { + const src = readFileSync(join(GENERATED_TESTS_DIR, file), 'utf8'); + assertionsRun++; + const contextRefs = new Set(); + const regexCtxRead = /ctx\['([^']+)'\]/g; + let match: RegExpExecArray | null; + while ((match = regexCtxRead.exec(src)) !== null) { + contextRefs.add(match[1]); + } + const boundVars = new Set(); + const regexCtxWrite = /ctx\['([^']+)'\]\s*=/g; + while ((match = regexCtxWrite.exec(src)) !== null) { + boundVars.add(match[1]); + } + for (const ref of contextRefs) { + if (!boundVars.has(ref)) { + offenders.push({ file, placeholder: ref, reason: 'ctx read without prior binding' }); + } + } + } + expect(assertionsRun).toBeGreaterThan(0); + expect(offenders).toEqual([]); + }); + + it('every planned scenario has a materialized Python SDK test file (#133)', () => { + const INDEX_PATH = join(SCENARIOS_DIR, 'index.json'); + if (!existsSync(INDEX_PATH)) { + throw new Error( + `Scenario index not found at ${INDEX_PATH}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`, + ); + } + // biome-ignore lint/plugin: runtime contract boundary for parsed JSON + const index = JSON.parse(readFileSync(INDEX_PATH, 'utf8')) as { + endpoints: Array<{ operationId: string; scenarioCount: number }>; + }; + const planned = index.endpoints.filter((e) => e.scenarioCount > 0); + if (planned.length === 0) { + return; + } + if (!existsSync(GENERATED_TESTS_DIR)) { + throw new Error( + `Generated tests directory not found at ${GENERATED_TESTS_DIR}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`, + ); + } + // Python SDK and JS SDK hard-fail on scenarios whose prereqs require multipart + // uploads (e.g. createDeployment). The emitters apply the same hard-fail logic, + // so the set of operations they CAN emit is identical. We use the JS SDK's + // emitted .feature.test.ts files as the reference set: any operationId covered + // by the JS SDK must also have a Python SDK file, and vice versa. + const jsSdkEmitted = new Set( + readdirSync(GENERATED_TESTS_DIR) + .filter((f) => f.endsWith('.feature.test.ts')) + .map((f) => f.replace(/\.feature\.test\.ts$/, '')), + ); + if (jsSdkEmitted.size === 0) { + throw new Error('No JS SDK .feature.test.ts files found — run codegen:js-sdk:all first.'); + } + const missing = planned + .filter((e) => jsSdkEmitted.has(e.operationId)) + .map((e) => `${e.operationId}.python_sdk.spec.py`) + .filter((f) => !existsSync(join(GENERATED_TESTS_DIR, f))); + expect( + missing, + 'Planned scenarios exist but no Python SDK test file was materialized for these operationIds', + ).toEqual([]); + }); +}); + +describeForThisConfig('bundled-spec invariants: emitted JS SDK suite (#131)', () => { + it('every URL placeholder in JS SDK suite is either seeded or extracted (mirrors Bug A)', () => { + // The JS SDK emitter resolves ${var} body-template placeholders to + // ctx["var"] at code-generation time. Any remaining ${...} literal in + // the emitted .test.ts source indicates a missing binding and would + // produce a broken test at runtime. + if (!existsSync(GENERATED_TESTS_DIR)) { + throw new Error( + `Generated tests directory not found at ${GENERATED_TESTS_DIR}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`, + ); + } + const files = readdirSync(GENERATED_TESTS_DIR).filter((f) => f.endsWith('.feature.test.ts')); + if (files.length === 0) { + return; + } + const offenders: string[] = []; + for (const f of files) { + const src = readFileSync(join(GENERATED_TESTS_DIR, f), 'utf8'); + if (/\$\{[^}]+\}/.test(src)) { + offenders.push(f); + } + } + expect( + offenders, + 'Emitted JS SDK test file(s) contain unresolved ${...} placeholder strings. ' + + 'The emitter must resolve every body-template placeholder to ctx[""] before emitting.', + ).toEqual([]); + }); + + it('every planned scenario has a materialized JS SDK test file (#131)', () => { + const INDEX_PATH = join(SCENARIOS_DIR, 'index.json'); + if (!existsSync(INDEX_PATH)) { + throw new Error( + `Scenario index not found at ${INDEX_PATH}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`, + ); + } + // biome-ignore lint/plugin: runtime contract boundary for parsed JSON + const index = JSON.parse(readFileSync(INDEX_PATH, 'utf8')) as { + endpoints: Array<{ operationId: string; scenarioCount: number }>; + }; + const planned = index.endpoints.filter((e) => e.scenarioCount > 0); + if (planned.length === 0) { + return; + } + if (!existsSync(GENERATED_TESTS_DIR)) { + throw new Error( + `Generated tests directory not found at ${GENERATED_TESTS_DIR}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`, + ); + } + // JS SDK and Python SDK hard-fail on scenarios whose prereqs require multipart + // uploads (e.g. createDeployment). The emitters apply the same hard-fail logic, + // so the set of operations they CAN emit is identical. We use the Python SDK's + // emitted .python_sdk.spec.py files as the reference set: any operationId + // covered by the Python SDK must also have a JS SDK file, and vice versa. + const pythonSdkEmitted = new Set( + readdirSync(GENERATED_TESTS_DIR) + .filter((f) => f.endsWith('.python_sdk.spec.py')) + .map((f) => f.replace(/\.python_sdk\.spec\.py$/, '')), + ); + if (pythonSdkEmitted.size === 0) { + throw new Error( + 'No Python SDK .python_sdk.spec.py files found — run codegen:python-sdk:all first.', + ); + } + const missing = planned + .filter((e) => pythonSdkEmitted.has(e.operationId)) + .map((e) => `${e.operationId}.feature.test.ts`) + .filter((f) => !existsSync(join(GENERATED_TESTS_DIR, f))); + expect( + missing, + 'Planned scenarios exist but no JS SDK test file was materialized for these operationIds', + ).toEqual([]); + }); +}); + +describeForThisConfig('bundled-spec invariants: emitted C# SDK suite (#132)', () => { + it('every emitted C# file is placed under the csharp/ subdirectory (#132)', () => { + if (!existsSync(GENERATED_TESTS_DIR)) { + throw new Error( + `Generated tests directory not found at ${GENERATED_TESTS_DIR}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`, + ); + } + const CSHARP_DIR = join(GENERATED_TESTS_DIR, 'csharp'); + if (!existsSync(CSHARP_DIR)) { + return; + } + const files = readdirSync(CSHARP_DIR).filter((f) => f.endsWith('.cs')); + if (files.length === 0) { + return; + } + const badNames = files.filter( + (f) => !/^[a-zA-Z][a-zA-Z0-9]+\.(feature|integration|variant)\.cs$/.test(f), + ); + expect( + badNames, + 'C# emitted files must follow ..cs naming convention', + ).toEqual([]); + }); + + it('every emitted C# file uses the Camunda.Orchestration.RestSdk.Generated namespace (#132)', () => { + if (!existsSync(GENERATED_TESTS_DIR)) { + throw new Error( + `Generated tests directory not found at ${GENERATED_TESTS_DIR}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`, + ); + } + const CSHARP_DIR = join(GENERATED_TESTS_DIR, 'csharp'); + if (!existsSync(CSHARP_DIR)) { + return; + } + const files = readdirSync(CSHARP_DIR).filter((f) => f.endsWith('.cs')); + if (files.length === 0) { + return; + } + const offenders: string[] = []; + for (const f of files) { + const src = readFileSync(join(CSHARP_DIR, f), 'utf8'); + if (!src.includes('namespace Camunda.Orchestration.RestSdk.Generated')) { + offenders.push(f); + } + } + expect( + offenders, + 'Emitted C# file(s) are missing the Camunda.Orchestration.RestSdk.Generated namespace declaration.', + ).toEqual([]); + }); +}); diff --git a/csharp-sdk/.gitignore b/csharp-sdk/.gitignore new file mode 100644 index 00000000..cd42ee34 --- /dev/null +++ b/csharp-sdk/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/csharp-sdk/AUTOMATION_BOUNDARY.md b/csharp-sdk/AUTOMATION_BOUNDARY.md new file mode 100644 index 00000000..9e38a9f4 --- /dev/null +++ b/csharp-sdk/AUTOMATION_BOUNDARY.md @@ -0,0 +1,44 @@ +# C# Semantic Type Automation Boundary + +This note captures which parts of the C# semantic type system can be generated from +OpenAPI `x-semantic-type` annotations, and which parts are intentionally manual. + +## What a generator can safely do + +- Emit `readonly record struct` wrappers for every `x-semantic-type` in the spec. +- Generate implicit conversions to and from `string` for ergonomic interop with + JSON and API payloads. +- Generate `ToString()` forwarding to the underlying `Value`. +- Group the generated types by domain (identifiers, keys, ids) in a single file + or a small file set. + +These choices are structural and do not require human policy decisions. + +## What should remain manual + +- **Type naming policy**: mapping `x-semantic-type` values to C# type names + (e.g., `ProcessDefinitionId` vs `ProcessDefinitionKey`) is mechanical, but + exceptions and aliases should be human-reviewed. +- **Nullability and default handling**: deciding when a type should be nullable + in DTOs is domain-specific and not always derivable from the schema alone. +- **API surface choices**: whether to include implicit conversions, explicit + `Parse`/`TryParse`, or validation checks is a usability decision that should + be reviewed by maintainers. +- **Packaging and namespaces**: project layout, namespace prefixes, and file + organization are repo conventions, not spec-derived data. + +## Recommended split of responsibilities + +| Area | Generator responsibility | Manual responsibility | +| --- | --- | --- | +| Value struct type list | Create one type per `x-semantic-type` | Rename or alias any type that is confusing or deprecated | +| Conversions + `ToString()` | Generate standard conversions | Decide if stricter parsing or validation is needed | +| DTO field typing | Use generated types where `x-semantic-type` exists | Override nullability or apply domain rules | +| Project structure | None | Decide file layout, namespaces, and packaging | + +## Practical guidance + +- Generate the initial `Identifiers.cs` file from the spec, then review and + curate it by hand for naming and nullability policy. +- Keep any manual edits in a separate patch or file so future regenerations + can be reviewed cleanly. diff --git a/csharp-sdk/GRPC_ORIENTATION.md b/csharp-sdk/GRPC_ORIENTATION.md new file mode 100644 index 00000000..f52c40b5 --- /dev/null +++ b/csharp-sdk/GRPC_ORIENTATION.md @@ -0,0 +1,66 @@ +# gRPC Orientation Notes (zeebe-client-csharp) + +This note documents the gRPC-only baseline of the community C# client so the +REST SDK work can be compared against it. + +## What was reviewed + +- `Client.Cloud.Example/Program.cs` shows `CamundaCloudClientBuilder` usage for + Camunda Cloud, then calls `TopologyRequest().Send()`. +- `Client.Examples/Program.cs` shows a local gateway flow: + deploy BPMN, create process instance, and open a job worker. + +## Observations + +- The client is a gRPC wrapper; all interaction is via gRPC commands. +- Key types are plain numeric values (e.g., `processDefinitionKey` and + `job.Key` are `long`-like values), with no distinct semantic wrappers. +- The job worker API uses `IJobClient`/`IJob` with plain numeric keys and + JSON string payloads. + +## Local run attempt + +- `dotnet build Zeebe.sln` succeeded, with analyzer warnings. +- Running the Camunda Cloud example was blocked by `global.json` requiring + .NET SDK 10.0.201 (only 8.0.420 is installed here). +- The `camunda-platform` repo no longer ships Docker Compose files; the README + points to the Camunda Docker Compose quickstart artifacts instead. +- Downloaded the Camunda 8 Docker Compose quickstart (8.9) from the Camunda + distributions release and extracted it locally. +- `docker compose up -d` initially failed with a port bind error on + `0.0.0.0:8080`. The compose file was updated to publish the HTTP port on + `8088` instead, and the stack started successfully. +- The Orchestration Cluster container is healthy; gRPC is listening on + `localhost:26500` and HTTP is reachable at `http://localhost:8088`. + +## Next steps to fully execute the quick start + +1. Install .NET SDK 10.0.201 or adjust `global.json` for a supported SDK. +2. Provide real Camunda Cloud credentials (`ZEEBE_CLIENT_ID`, + `ZEEBE_CLIENT_SECRET`, `ZEEBE_ADDRESS`) or run against a local gateway. +3. Re-run the Cloud example and record the runtime outputs. + +## Next steps for local Docker Compose + +1. Re-run `docker compose up -d` in the extracted quickstart directory. +2. Verify the Orchestration Cluster is reachable: + - REST API: `http://localhost:8088/v2` + - gRPC API: `localhost:26500` +3. Re-run the local example and capture the console output. + +## Local Zeebe gateway run recipe (when credentials are unavailable) + +1. Start a local Camunda 8 / Zeebe gateway (for example, via your preferred + docker-based dev stack) and confirm the gateway is reachable at a host:port + such as `0.0.0.0:26500`. +2. In the zeebe client repo, run the local examples project: + + ```bash + export PATH="$HOME/.dotnet:$PATH" + dotnet run --project Client.Examples/Client.Examples.csproj + ``` + +3. If your gateway is not on the default `0.0.0.0:26500`, update the + `ZeebeUrl` constant in `Client.Examples/Program.cs` before running. +4. Confirm the deploy → create instance → job worker sequence completes and + record the key types observed in the console output. diff --git a/csharp-sdk/README.md b/csharp-sdk/README.md new file mode 100644 index 00000000..fb904e42 --- /dev/null +++ b/csharp-sdk/README.md @@ -0,0 +1,27 @@ +# Camunda Orchestration REST SDK (C#) + +Minimal REST client scaffold intended for the api-test-generator SDK emitter. + +Status: minimal endpoints and DTOs are stubbed and should be verified against the +bundled OpenAPI spec before production use. + +## Included endpoints + +- Create deployment +- Create process instance +- Cancel process instance +- Search process instances +- Activate jobs +- Complete job + +## Usage (sample) + +See [csharp-sdk/examples/usage.cs](csharp-sdk/examples/usage.cs) for a minimal example. + +## Build + +Requires .NET SDK 8.x: + +```bash +dotnet build csharp-sdk/src/Camunda.Orchestration.RestSdk/Camunda.Orchestration.RestSdk.csproj +``` diff --git a/csharp-sdk/examples/operation-map.json b/csharp-sdk/examples/operation-map.json new file mode 100644 index 00000000..8cb42b19 --- /dev/null +++ b/csharp-sdk/examples/operation-map.json @@ -0,0 +1,65 @@ +{ + "createDeployment": [ + { + "file": "Client/OrchestrationClusterClient.cs", + "region": "CreateDeploymentAsync", + "label": "Deploy resources" + } + ], + "createProcessInstance": [ + { + "file": "Client/OrchestrationClusterClient.cs", + "region": "CreateProcessInstanceAsync", + "label": "Create process instance" + } + ], + "searchProcessInstances": [ + { + "file": "Client/OrchestrationClusterClient.cs", + "region": "SearchProcessInstancesAsync", + "label": "Search process instances" + } + ], + "activateJobs": [ + { + "file": "Client/OrchestrationClusterClient.cs", + "region": "ActivateJobsAsync", + "label": "Activate jobs" + } + ], + "completeJob": [ + { + "file": "Client/OrchestrationClusterClient.cs", + "region": "CompleteJobAsync", + "label": "Complete job" + } + ], + "cancelProcessInstance": [ + { + "file": "Client/OrchestrationClusterClient.cs", + "region": "CancelProcessInstanceAsync", + "label": "Cancel process instance" + } + ], + "getProcessInstance": [ + { + "file": "Client/OrchestrationClusterClient.cs", + "region": "GetProcessInstanceAsync", + "label": "Get process instance" + } + ], + "searchJobs": [ + { + "file": "Client/OrchestrationClusterClient.cs", + "region": "SearchJobsAsync", + "label": "Search jobs" + } + ], + "failJob": [ + { + "file": "Client/OrchestrationClusterClient.cs", + "region": "FailJobAsync", + "label": "Fail job" + } + ] +} \ No newline at end of file diff --git a/csharp-sdk/examples/usage.cs b/csharp-sdk/examples/usage.cs new file mode 100644 index 00000000..60bdf99c --- /dev/null +++ b/csharp-sdk/examples/usage.cs @@ -0,0 +1,47 @@ +using Camunda.Orchestration.RestSdk.Client; +using Camunda.Orchestration.RestSdk.Models; +using Camunda.Orchestration.RestSdk.Types; + +var httpClient = new HttpClient(); +var client = new OrchestrationClusterClient( + httpClient, + new ClientOptions { BaseUri = new Uri("http://localhost:8080/v2/") } +); + +var deployment = await client.CreateDeploymentAsync( + new DeploymentRequest + { + TenantId = new TenantId(""), + Resources = new List + { + new( + FileName: "process.bpmn", + ContentType: "application/octet-stream", + Content: await File.ReadAllBytesAsync("process.bpmn") + ), + }, + } +); + +var createInstance = await client.CreateProcessInstanceAsync( + new CreateProcessInstanceRequest + { + ProcessDefinitionKey = deployment.Deployments[0].ProcessDefinition?.ProcessDefinitionKey, + Variables = new Dictionary { ["foo"] = "bar" }, + } +); + +var activation = await client.ActivateJobsAsync( + new ActivateJobsRequest + { + Type = "service-task", + Timeout = 45000, + MaxJobsToActivate = 1, + Worker = "sdk-sample", + } +); + +if (activation.Jobs.Count > 0) +{ + await client.CompleteJobAsync(activation.Jobs[0].JobKey!, new CompleteJobRequest()); +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Camunda.Orchestration.RestSdk.csproj b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Camunda.Orchestration.RestSdk.csproj new file mode 100644 index 00000000..e8cd5992 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Camunda.Orchestration.RestSdk.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Client/ClientOptions.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Client/ClientOptions.cs new file mode 100644 index 00000000..26595601 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Client/ClientOptions.cs @@ -0,0 +1,7 @@ +namespace Camunda.Orchestration.RestSdk.Client; + +public sealed class ClientOptions +{ + public required Uri BaseUri { get; init; } + public string? BearerToken { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Client/OrchestrationClusterClient.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Client/OrchestrationClusterClient.cs new file mode 100644 index 00000000..3f0f77c1 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Client/OrchestrationClusterClient.cs @@ -0,0 +1,203 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Camunda.Orchestration.RestSdk.Models; +using Camunda.Orchestration.RestSdk.Types; + +namespace Camunda.Orchestration.RestSdk.Client; + +public sealed class OrchestrationClusterClient +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient httpClient; + private readonly Uri baseUri; + + public OrchestrationClusterClient(HttpClient httpClient, Uri baseUri, string? bearerToken = null) + { + this.httpClient = httpClient; + this.baseUri = baseUri; + if (!string.IsNullOrWhiteSpace(bearerToken)) + { + this.httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", bearerToken); + } + } + + public OrchestrationClusterClient(HttpClient httpClient, ClientOptions options) + : this(httpClient, options.BaseUri, options.BearerToken) + { + } + + public Uri BaseUri => baseUri; + + public async Task CreateDeploymentAsync( + DeploymentRequest request, + CancellationToken cancellationToken = default) + { + using var content = new MultipartFormDataContent(); + if (request.TenantId is not null) + { + content.Add(new StringContent(request.TenantId.ToString()!, Encoding.UTF8), "tenantId"); + } + foreach (var resource in request.Resources) + { + var bytes = new ByteArrayContent(resource.Content); + bytes.Headers.ContentType = new MediaTypeHeaderValue(resource.ContentType); + content.Add(bytes, "resources", resource.FileName); + } + + var response = await httpClient.PostAsync(BuildUri("/deployments"), content, cancellationToken); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + return Deserialize(body); + } + + public async Task CreateProcessInstanceAsync( + CreateProcessInstanceRequest request, + CancellationToken cancellationToken = default) + { + return await PostJsonAsync( + "/process-instances", + request, + cancellationToken); + } + + public async Task CancelProcessInstanceAsync( + ProcessInstanceKey processInstanceKey, + CancelProcessInstanceRequest? request = null, + CancellationToken cancellationToken = default) + { + var path = $"/process-instances/{processInstanceKey}/cancellation"; + if (request is null) + { + await PostNoContentAsync(path, cancellationToken); + return; + } + await PostJsonNoResponseAsync(path, request, cancellationToken); + } + + public async Task SearchProcessInstancesAsync( + SearchProcessInstancesRequest request, + CancellationToken cancellationToken = default) + { + return await PostJsonAsync( + "/process-instances/search", + request, + cancellationToken); + } + + public async Task GetProcessInstanceAsync( + ProcessInstanceKey processInstanceKey, + CancellationToken cancellationToken = default) + { + return await GetJsonAsync( + $"/process-instances/{processInstanceKey}", + cancellationToken); + } + + public async Task ActivateJobsAsync( + ActivateJobsRequest request, + CancellationToken cancellationToken = default) + { + return await PostJsonAsync( + "/jobs/activation", + request, + cancellationToken); + } + + public async Task SearchJobsAsync( + JobSearchRequest request, + CancellationToken cancellationToken = default) + { + return await PostJsonAsync( + "/jobs/search", + request, + cancellationToken); + } + + public async Task CompleteJobAsync( + JobKey jobKey, + CompleteJobRequest? request = null, + CancellationToken cancellationToken = default) + { + var path = $"/jobs/{jobKey}/completion"; + if (request is null) + { + await PostNoContentAsync(path, cancellationToken); + return; + } + await PostJsonNoResponseAsync(path, request, cancellationToken); + } + + public async Task FailJobAsync( + JobKey jobKey, + JobFailRequest? request = null, + CancellationToken cancellationToken = default) + { + var path = $"/jobs/{jobKey}/failure"; + if (request is null) + { + await PostNoContentAsync(path, cancellationToken); + return; + } + await PostJsonNoResponseAsync(path, request, cancellationToken); + } + + private async Task PostJsonAsync( + string path, + TRequest request, + CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(request, JsonOptions); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await httpClient.PostAsync(BuildUri(path), content, cancellationToken); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + return Deserialize(body); + } + + private async Task GetJsonAsync( + string path, + CancellationToken cancellationToken) + { + var response = await httpClient.GetAsync(BuildUri(path), cancellationToken); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + return Deserialize(body); + } + + private async Task PostJsonNoResponseAsync( + string path, + TRequest request, + CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(request, JsonOptions); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await httpClient.PostAsync(BuildUri(path), content, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + private async Task PostNoContentAsync(string path, CancellationToken cancellationToken) + { + var response = await httpClient.PostAsync(BuildUri(path), content: null, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + private Uri BuildUri(string path) => new(baseUri, path); + + private static T Deserialize(string json) + { + var result = JsonSerializer.Deserialize(json, JsonOptions); + if (result is null) + { + throw new InvalidOperationException("Response payload was empty or invalid JSON."); + } + return result; + } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/ActivateJobsRequest.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/ActivateJobsRequest.cs new file mode 100644 index 00000000..0ccc6019 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/ActivateJobsRequest.cs @@ -0,0 +1,15 @@ +using Camunda.Orchestration.RestSdk.Types; + +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record ActivateJobsRequest +{ + public required string Type { get; init; } + public required long Timeout { get; init; } + public required int MaxJobsToActivate { get; init; } + public string? Worker { get; init; } + public int? RequestTimeout { get; init; } + public IReadOnlyList? FetchVariable { get; init; } + public IReadOnlyList? TenantIds { get; init; } + public string? TenantFilter { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/ActivateJobsResponse.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/ActivateJobsResponse.cs new file mode 100644 index 00000000..bef511b4 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/ActivateJobsResponse.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using Camunda.Orchestration.RestSdk.Types; + +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record ActivateJobsResponse +{ + public IReadOnlyList Jobs { get; init; } = Array.Empty(); +} + +public sealed record ActivatedJob +{ + public JobKey? JobKey { get; init; } + public ProcessInstanceKey? ProcessInstanceKey { get; init; } + public ProcessDefinitionKey? ProcessDefinitionKey { get; init; } + public ProcessDefinitionId? ProcessDefinitionId { get; init; } + public int? ProcessDefinitionVersion { get; init; } + public ElementId? ElementId { get; init; } + public string? Type { get; init; } + public string? Worker { get; init; } + public int? Retries { get; init; } + public long? Deadline { get; init; } + public JsonElement? Variables { get; init; } + public JsonElement? CustomHeaders { get; init; } + public TenantId? TenantId { get; init; } + public ElementInstanceKey? ElementInstanceKey { get; init; } + public string? Kind { get; init; } + public string? ListenerEventType { get; init; } + public ProcessInstanceKey? RootProcessInstanceKey { get; init; } + public IReadOnlyList? Tags { get; init; } + public JsonElement? UserTask { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/CancelProcessInstanceRequest.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/CancelProcessInstanceRequest.cs new file mode 100644 index 00000000..b8e305a2 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/CancelProcessInstanceRequest.cs @@ -0,0 +1,6 @@ +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record CancelProcessInstanceRequest +{ + public long? OperationReference { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/CompleteJobRequest.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/CompleteJobRequest.cs new file mode 100644 index 00000000..befba629 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/CompleteJobRequest.cs @@ -0,0 +1,25 @@ +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record CompleteJobRequest +{ + public Dictionary? Variables { get; init; } + public JobResult? Result { get; init; } +} + +public sealed record JobResult +{ + public string? Type { get; init; } + public bool? Denied { get; init; } + public string? DeniedReason { get; init; } + public JobResultCorrections? Corrections { get; init; } +} + +public sealed record JobResultCorrections +{ + public string? Assignee { get; init; } + public DateTimeOffset? DueDate { get; init; } + public DateTimeOffset? FollowUpDate { get; init; } + public IReadOnlyList? CandidateUsers { get; init; } + public IReadOnlyList? CandidateGroups { get; init; } + public int? Priority { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/CreateProcessInstanceRequest.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/CreateProcessInstanceRequest.cs new file mode 100644 index 00000000..db06ec60 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/CreateProcessInstanceRequest.cs @@ -0,0 +1,31 @@ +using Camunda.Orchestration.RestSdk.Types; + +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record CreateProcessInstanceRequest +{ + public ProcessDefinitionKey? ProcessDefinitionKey { get; init; } + public ProcessDefinitionId? ProcessDefinitionId { get; init; } + public int? ProcessDefinitionVersion { get; init; } + public Dictionary? Variables { get; init; } + public TenantId? TenantId { get; init; } + public long? OperationReference { get; init; } + public IReadOnlyList? StartInstructions { get; init; } + public IReadOnlyList? RuntimeInstructions { get; init; } + public bool? AwaitCompletion { get; init; } + public IReadOnlyList? FetchVariables { get; init; } + public long? RequestTimeout { get; init; } + public IReadOnlyList? Tags { get; init; } + public BusinessId? BusinessId { get; init; } +} + +public sealed record StartInstruction +{ + public ElementId? ElementId { get; init; } +} + +public sealed record RuntimeInstruction +{ + public string? Type { get; init; } + public ElementId? AfterElementId { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/CreateProcessInstanceResponse.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/CreateProcessInstanceResponse.cs new file mode 100644 index 00000000..3d0f1d39 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/CreateProcessInstanceResponse.cs @@ -0,0 +1,15 @@ +using Camunda.Orchestration.RestSdk.Types; + +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record CreateProcessInstanceResponse +{ + public ProcessDefinitionId? ProcessDefinitionId { get; init; } + public int? ProcessDefinitionVersion { get; init; } + public TenantId? TenantId { get; init; } + public Dictionary? Variables { get; init; } + public ProcessDefinitionKey? ProcessDefinitionKey { get; init; } + public ProcessInstanceKey? ProcessInstanceKey { get; init; } + public IReadOnlyList? Tags { get; init; } + public BusinessId? BusinessId { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/DeploymentRequest.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/DeploymentRequest.cs new file mode 100644 index 00000000..3ac8fba6 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/DeploymentRequest.cs @@ -0,0 +1,9 @@ +using Camunda.Orchestration.RestSdk.Types; + +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record DeploymentRequest +{ + public IReadOnlyList Resources { get; init; } = Array.Empty(); + public TenantId? TenantId { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/DeploymentResource.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/DeploymentResource.cs new file mode 100644 index 00000000..c4022b8f --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/DeploymentResource.cs @@ -0,0 +1,7 @@ +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record DeploymentResource( + string FileName, + string ContentType, + byte[] Content +); diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/DeploymentResponse.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/DeploymentResponse.cs new file mode 100644 index 00000000..96d39116 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/DeploymentResponse.cs @@ -0,0 +1,68 @@ +using Camunda.Orchestration.RestSdk.Types; + +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record DeploymentResponse +{ + public DeploymentKey? DeploymentKey { get; init; } + public TenantId? TenantId { get; init; } + public IReadOnlyList Deployments { get; init; } = + Array.Empty(); +} + +public sealed record DeploymentMetadataResult +{ + public DeploymentProcessResult? ProcessDefinition { get; init; } + public DeploymentDecisionResult? DecisionDefinition { get; init; } + public DeploymentDecisionRequirementsResult? DecisionRequirements { get; init; } + public DeploymentFormResult? Form { get; init; } + public DeploymentResourceResult? Resource { get; init; } +} + +public sealed record DeploymentProcessResult +{ + public ProcessDefinitionId? ProcessDefinitionId { get; init; } + public int? ProcessDefinitionVersion { get; init; } + public string? ResourceName { get; init; } + public TenantId? TenantId { get; init; } + public ProcessDefinitionKey? ProcessDefinitionKey { get; init; } +} + +public sealed record DeploymentDecisionResult +{ + public DecisionDefinitionId? DecisionDefinitionId { get; init; } + public int? Version { get; init; } + public string? Name { get; init; } + public TenantId? TenantId { get; init; } + public string? DecisionRequirementsId { get; init; } + public DecisionDefinitionKey? DecisionDefinitionKey { get; init; } + public DecisionRequirementsKey? DecisionRequirementsKey { get; init; } +} + +public sealed record DeploymentDecisionRequirementsResult +{ + public string? DecisionRequirementsId { get; init; } + public string? DecisionRequirementsName { get; init; } + public int? Version { get; init; } + public string? ResourceName { get; init; } + public TenantId? TenantId { get; init; } + public DecisionRequirementsKey? DecisionRequirementsKey { get; init; } +} + +public sealed record DeploymentFormResult +{ + public FormId? FormId { get; init; } + public int? Version { get; init; } + public string? ResourceName { get; init; } + public TenantId? TenantId { get; init; } + public FormKey? FormKey { get; init; } +} + +public sealed record DeploymentResourceResult +{ + public string? ResourceId { get; init; } + public string? ResourceName { get; init; } + public int? Version { get; init; } + public TenantId? TenantId { get; init; } + public ResourceKey? ResourceKey { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/JobFailRequest.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/JobFailRequest.cs new file mode 100644 index 00000000..3b756a79 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/JobFailRequest.cs @@ -0,0 +1,9 @@ +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record JobFailRequest +{ + public int? Retries { get; init; } + public string? ErrorMessage { get; init; } + public long? RetryBackOff { get; init; } + public Dictionary? Variables { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/JobSearchModels.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/JobSearchModels.cs new file mode 100644 index 00000000..7c8ca6fd --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/JobSearchModels.cs @@ -0,0 +1,48 @@ +using Camunda.Orchestration.RestSdk.Types; + +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record JobSearchRequest : SearchQueryRequest +{ + public IReadOnlyList? Sort { get; init; } + public Dictionary? Filter { get; init; } +} + +public sealed record JobSearchSort +{ + public string? Field { get; init; } + public SortOrder? Order { get; init; } +} + +public sealed record JobSearchResponse : SearchQueryResponse +{ + public IReadOnlyList Items { get; init; } = Array.Empty(); +} + +public sealed record JobSearchResult +{ + public Dictionary? CustomHeaders { get; init; } + public ElementId? ElementId { get; init; } + public ElementInstanceKey? ElementInstanceKey { get; init; } + public bool? HasFailedWithRetriesLeft { get; init; } + public JobKey? JobKey { get; init; } + public string? Kind { get; init; } + public string? ListenerEventType { get; init; } + public ProcessDefinitionId? ProcessDefinitionId { get; init; } + public ProcessDefinitionKey? ProcessDefinitionKey { get; init; } + public ProcessInstanceKey? ProcessInstanceKey { get; init; } + public int? Retries { get; init; } + public string? State { get; init; } + public TenantId? TenantId { get; init; } + public string? Type { get; init; } + public string? Worker { get; init; } + public ProcessInstanceKey? RootProcessInstanceKey { get; init; } + public DateTimeOffset? CreationTime { get; init; } + public DateTimeOffset? Deadline { get; init; } + public string? DeniedReason { get; init; } + public DateTimeOffset? EndTime { get; init; } + public string? ErrorCode { get; init; } + public string? ErrorMessage { get; init; } + public bool? IsDenied { get; init; } + public DateTimeOffset? LastUpdateTime { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/SearchProcessInstancesRequest.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/SearchProcessInstancesRequest.cs new file mode 100644 index 00000000..243b6682 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/SearchProcessInstancesRequest.cs @@ -0,0 +1,15 @@ +using Camunda.Orchestration.RestSdk.Types; + +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record SearchProcessInstancesRequest : SearchQueryRequest +{ + public IReadOnlyList? Sort { get; init; } + public Dictionary? Filter { get; init; } +} + +public sealed record ProcessInstanceSearchSort +{ + public string? Field { get; init; } + public SortOrder? Order { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/SearchProcessInstancesResponse.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/SearchProcessInstancesResponse.cs new file mode 100644 index 00000000..e6f51d4e --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/SearchProcessInstancesResponse.cs @@ -0,0 +1,28 @@ +using Camunda.Orchestration.RestSdk.Types; + +namespace Camunda.Orchestration.RestSdk.Models; + +public sealed record SearchProcessInstancesResponse : SearchQueryResponse +{ + public IReadOnlyList Items { get; init; } = Array.Empty(); +} + +public sealed record ProcessInstanceResult +{ + public ProcessDefinitionId? ProcessDefinitionId { get; init; } + public string? ProcessDefinitionName { get; init; } + public int? ProcessDefinitionVersion { get; init; } + public string? ProcessDefinitionVersionTag { get; init; } + public DateTimeOffset? StartDate { get; init; } + public DateTimeOffset? EndDate { get; init; } + public string? State { get; init; } + public bool? HasIncident { get; init; } + public TenantId? TenantId { get; init; } + public ProcessInstanceKey? ProcessInstanceKey { get; init; } + public ProcessDefinitionKey? ProcessDefinitionKey { get; init; } + public ProcessInstanceKey? ParentProcessInstanceKey { get; init; } + public ElementInstanceKey? ParentElementInstanceKey { get; init; } + public ProcessInstanceKey? RootProcessInstanceKey { get; init; } + public IReadOnlyList? Tags { get; init; } + public BusinessId? BusinessId { get; init; } +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/SearchQuery.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/SearchQuery.cs new file mode 100644 index 00000000..522d22d3 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Models/SearchQuery.cs @@ -0,0 +1,33 @@ +namespace Camunda.Orchestration.RestSdk.Models; + +public record SearchQueryRequest +{ + public SearchQueryPageRequest? Page { get; init; } +} + +public sealed record SearchQueryPageRequest +{ + public int? Limit { get; init; } + public int? From { get; init; } + public string? After { get; init; } + public string? Before { get; init; } +} + +public record SearchQueryResponse +{ + public SearchQueryPageResponse? Page { get; init; } +} + +public sealed record SearchQueryPageResponse +{ + public long? TotalItems { get; init; } + public bool? HasMoreTotalItems { get; init; } + public string? StartCursor { get; init; } + public string? EndCursor { get; init; } +} + +public enum SortOrder +{ + ASC, + DESC, +} diff --git a/csharp-sdk/src/Camunda.Orchestration.RestSdk/Types/Identifiers.cs b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Types/Identifiers.cs new file mode 100644 index 00000000..10303358 --- /dev/null +++ b/csharp-sdk/src/Camunda.Orchestration.RestSdk/Types/Identifiers.cs @@ -0,0 +1,113 @@ +namespace Camunda.Orchestration.RestSdk.Types; + +public readonly record struct ProcessInstanceKey(string Value) +{ + public static implicit operator ProcessInstanceKey(string value) => new(value); + public static implicit operator string(ProcessInstanceKey value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct ProcessDefinitionId(string Value) +{ + public static implicit operator ProcessDefinitionId(string value) => new(value); + public static implicit operator string(ProcessDefinitionId value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct ProcessDefinitionKey(string Value) +{ + public static implicit operator ProcessDefinitionKey(string value) => new(value); + public static implicit operator string(ProcessDefinitionKey value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct JobKey(string Value) +{ + public static implicit operator JobKey(string value) => new(value); + public static implicit operator string(JobKey value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct ElementInstanceKey(string Value) +{ + public static implicit operator ElementInstanceKey(string value) => new(value); + public static implicit operator string(ElementInstanceKey value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct ElementId(string Value) +{ + public static implicit operator ElementId(string value) => new(value); + public static implicit operator string(ElementId value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct UserTaskKey(string Value) +{ + public static implicit operator UserTaskKey(string value) => new(value); + public static implicit operator string(UserTaskKey value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct DecisionDefinitionKey(string Value) +{ + public static implicit operator DecisionDefinitionKey(string value) => new(value); + public static implicit operator string(DecisionDefinitionKey value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct DecisionDefinitionId(string Value) +{ + public static implicit operator DecisionDefinitionId(string value) => new(value); + public static implicit operator string(DecisionDefinitionId value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct DecisionRequirementsKey(string Value) +{ + public static implicit operator DecisionRequirementsKey(string value) => new(value); + public static implicit operator string(DecisionRequirementsKey value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct FormKey(string Value) +{ + public static implicit operator FormKey(string value) => new(value); + public static implicit operator string(FormKey value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct FormId(string Value) +{ + public static implicit operator FormId(string value) => new(value); + public static implicit operator string(FormId value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct DeploymentKey(string Value) +{ + public static implicit operator DeploymentKey(string value) => new(value); + public static implicit operator string(DeploymentKey value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct ResourceKey(string Value) +{ + public static implicit operator ResourceKey(string value) => new(value); + public static implicit operator string(ResourceKey value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct TenantId(string Value) +{ + public static implicit operator TenantId(string value) => new(value); + public static implicit operator string(TenantId value) => value.Value; + public override string ToString() => Value; +} + +public readonly record struct BusinessId(string Value) +{ + public static implicit operator BusinessId(string value) => new(value); + public static implicit operator string(BusinessId value) => value.Value; + public override string ToString() => Value; +} diff --git a/materializer/scripts/copy-support-templates.js b/materializer/scripts/copy-support-templates.js index 22cf55ec..f4722e3b 100644 --- a/materializer/scripts/copy-support-templates.js +++ b/materializer/scripts/copy-support-templates.js @@ -1,7 +1,7 @@ #!/usr/bin/env node // --------------------------------------------------------------------------- -// Stages the runtime support templates that the Playwright emitter vendors -// into every generated test suite. +// Stages the runtime support templates that the Playwright and JS SDK +// emitters vendor into every generated test suite. // // The canonical runtime support sources live in src/playwright/support/. // Some logic here is intentionally duplicated in analyser-owned code @@ -18,11 +18,20 @@ // fixtures.ts // seed-rules.json // await-eventually.ts +// dist/src/codegen/js-sdk/support-templates/ +// seeding.ts +// seed-rules.json +// dist/src/codegen/js-sdk/project-templates/ +// package.json +// tsconfig.json +// vitest.config.ts +// .env.example +// README.md // --------------------------------------------------------------------------- import { promises as fs } from 'node:fs'; import path from 'node:path'; -const SUPPORT_FILES = [ +const PLAYWRIGHT_SUPPORT_FILES = [ 'env.ts', 'recorder.ts', 'seeding.ts', @@ -31,14 +40,57 @@ const SUPPORT_FILES = [ 'await-eventually.ts', ]; +const SDK_SUPPORT_FILES = ['seeding.ts', 'seed-rules.json']; + +const SDK_PROJECT_FILES = [ + 'package.json', + 'tsconfig.json', + 'vitest.config.ts', + '.env.example', + 'README.md', +]; + async function main() { const root = process.cwd(); - const srcDir = path.join(root, 'src/playwright/support'); - const destDir = path.join(root, 'dist/src/playwright/support-templates'); - await fs.mkdir(destDir, { recursive: true }); - for (const name of SUPPORT_FILES) { - const src = path.join(srcDir, name); - const dest = path.join(destDir, name); + const supportSrcDir = path.join(root, 'src/playwright/support'); + + // Playwright support templates + const playwrightDestDir = path.join(root, 'dist/src/playwright/support-templates'); + await fs.mkdir(playwrightDestDir, { recursive: true }); + for (const name of PLAYWRIGHT_SUPPORT_FILES) { + const src = path.join(supportSrcDir, name); + const dest = path.join(playwrightDestDir, name); + try { + await fs.access(src); + } catch { + console.error('[copy-support-templates] source not found:', src); + process.exit(1); + } + await fs.copyFile(src, dest); + } + console.log( + `[copy-support-templates] staged ${PLAYWRIGHT_SUPPORT_FILES.length} playwright templates -> ${path.relative(root, playwrightDestDir)}`, + ); + + // JS SDK support templates (subset of Playwright support) + const sdkSupportDestDir = path.join(root, 'dist/src/codegen/js-sdk/support-templates'); + await fs.mkdir(sdkSupportDestDir, { recursive: true }); + for (const name of SDK_SUPPORT_FILES) { + const src = path.join(supportSrcDir, name); + const dest = path.join(sdkSupportDestDir, name); + await fs.copyFile(src, dest); + } + console.log( + `[copy-support-templates] staged ${SDK_SUPPORT_FILES.length} js-sdk support templates -> ${path.relative(root, sdkSupportDestDir)}`, + ); + + // JS SDK project templates (package.json, tsconfig, vitest.config, etc.) + const sdkProjSrcDir = path.join(root, 'src/codegen/js-sdk/project-templates'); + const sdkProjDestDir = path.join(root, 'dist/src/codegen/js-sdk/project-templates'); + await fs.mkdir(sdkProjDestDir, { recursive: true }); + for (const name of SDK_PROJECT_FILES) { + const src = path.join(sdkProjSrcDir, name); + const dest = path.join(sdkProjDestDir, name); try { await fs.access(src); } catch { @@ -48,7 +100,7 @@ async function main() { await fs.copyFile(src, dest); } console.log( - `[copy-support-templates] staged ${SUPPORT_FILES.length} templates -> ${path.relative(root, destDir)}`, + `[copy-support-templates] staged ${SDK_PROJECT_FILES.length} js-sdk project templates -> ${path.relative(root, sdkProjDestDir)}`, ); } diff --git a/materializer/src/csharp-sdk/README.md b/materializer/src/csharp-sdk/README.md new file mode 100644 index 00000000..15bbe6e9 --- /dev/null +++ b/materializer/src/csharp-sdk/README.md @@ -0,0 +1,51 @@ +# C# SDK Emitter (`--target=csharp-sdk`) + +Lowers `EndpointScenarioCollection` graphs onto the +[Camunda.Orchestration.RestSdk](https://github.com/camunda/camunda-orchestration-rest-sdk-csharp) +C# SDK and emits self-contained **.NET 8** test classes. + +## Generated file layout + +``` +/ + CamundaIntegrationTests.csproj + .env.example + README.md + csharp/ + .feature.cs + .integration.cs + .variant.cs +``` + +## Running the generated suite + +```bash +cd +cp .env.example .env # fill in connection details +dotnet restore +dotnet run +``` + +## Prerequisites + +The C# operation-map file must be present before generation. The map is +bundled with the `csharp-sdk/` workspace under `operation-map.ts`. Unlike +the JS/Python SDK emitters, the C# map is a static JSON document committed +to the repository — no separate fetch step is required. + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `CAMUNDA_BASE_URL` | `http://localhost:8080/v2/` | Camunda REST API base URL | +| `CAMUNDA_CLIENT_ID` | — | OAuth2 client ID (SaaS only) | +| `CAMUNDA_CLIENT_SECRET` | — | OAuth2 client secret (SaaS only) | +| `CAMUNDA_OAUTH_URL` | — | OAuth2 token endpoint URL (SaaS only) | + +## Code structure + +Each emitted `.cs` file declares a single `public static class GeneratedSuite` +in the `Camunda.Orchestration.RestSdk.Generated` namespace. Each scenario +becomes an `async Task` method. Helper methods (`FromTemplate`, +`ResolveTemplate`, `ApplyExtract`, `TryExtract`) are inlined per file so the +suite is self-contained with no cross-file dependencies. diff --git a/materializer/src/csharp-sdk/emitter.ts b/materializer/src/csharp-sdk/emitter.ts new file mode 100644 index 00000000..68124980 --- /dev/null +++ b/materializer/src/csharp-sdk/emitter.ts @@ -0,0 +1,407 @@ +import type { EmitContext, EmittedFile, EmitterStrategy } from '@camunda8/emitter-sdk'; +import type { EndpointScenarioCollection } from 'path-analyser/types'; + +export interface CsharpOperationMapEntry { + file?: string; + region?: string; + label?: string; +} + +export type CsharpOperationMap = Record; + +export function createCsharpEmitter(map: CsharpOperationMap): EmitterStrategy { + return { + id: 'csharp-sdk', + name: 'C# SDK', + supportedConfigs: ['*'], + async emit(collection: EndpointScenarioCollection, ctx: EmitContext): Promise { + const relativePath = `csharp/${collection.endpoint.operationId}.${ctx.mode}.cs`; + const content = renderCsharpSuite(collection, ctx, map); + return [{ relativePath, content }]; + }, + }; +} + +function renderCsharpSuite( + collection: EndpointScenarioCollection, + ctx: EmitContext, + map: CsharpOperationMap, +): string { + const lines: string[] = []; + const suiteName = ctx.suiteName || collection.endpoint.operationId; + lines.push('using System;'); + lines.push('using System.Collections.Generic;'); + lines.push('using System.IO;'); + lines.push('using System.Net.Http;'); + lines.push('using System.Text.Json;'); + lines.push('using System.Threading.Tasks;'); + lines.push('using Camunda.Orchestration.RestSdk.Client;'); + lines.push('using Camunda.Orchestration.RestSdk.Models;'); + lines.push('using Camunda.Orchestration.RestSdk.Types;'); + lines.push(''); + lines.push('namespace Camunda.Orchestration.RestSdk.Generated;'); + lines.push(''); + lines.push('public static class GeneratedSuite'); + lines.push('{'); + lines.push(` public static async Task ${pascalCase(suiteName)}Async()`); + lines.push(' {'); + lines.push(' using var httpClient = new HttpClient();'); + lines.push(' DotNetEnv.Env.TraversePath().Load();'); + lines.push( + ' var baseUri = Environment.GetEnvironmentVariable("CAMUNDA_BASE_URL") ?? "http://localhost:8080/v2/";', + ); + lines.push(' var client = new OrchestrationClusterClient('); + lines.push(' httpClient,'); + lines.push(' new ClientOptions { BaseUri = new Uri(baseUri) }'); + lines.push(' );'); + lines.push(' var ctx = new Dictionary();'); + lines.push(''); + for (const scenario of collection.scenarios) { + lines.push(` // Scenario ${scenario.id}${scenario.name ? ` - ${scenario.name}` : ''}`); + const ops = scenario.operations?.map((o) => o.operationId).join(' -> '); + if (ops) lines.push(` // Chain: ${ops}`); + if (!scenario.requestPlan || scenario.requestPlan.length === 0) { + lines.push(' // TODO: No request plan available'); + lines.push(''); + continue; + } + for (const step of scenario.requestPlan) { + lines.push(` // Step: ${step.operationId}`); + const methodName = resolveMethodName(step.operationId, map); + if (!methodName) { + lines.push(` // TODO: No SDK mapping for ${step.operationId}`); + lines.push(''); + continue; + } + emitStep(lines, step.operationId, methodName, step); + lines.push(''); + } + } + lines.push(' }'); + lines.push(''); + lines.push(' private static T FromTemplate(object? template)'); + lines.push(' {'); + lines.push(' var json = JsonSerializer.Serialize(template);'); + lines.push(' var result = JsonSerializer.Deserialize(json);'); + lines.push(' if (result is null)'); + lines.push(' {'); + lines.push( + ' throw new InvalidOperationException("Template deserialization failed.");', + ); + lines.push(' }'); + lines.push(' return result;'); + lines.push(' }'); + lines.push(''); + lines.push( + ' private static object? ResolveTemplate(object? template, Dictionary ctx)', + ); + lines.push(' {'); + lines.push(' if (template is null) return null;'); + lines.push(' if (template is string s)'); + lines.push(' {'); + // biome-ignore lint/suspicious/noTemplateCurlyInString: emitting C# string literal that checks for ${...} runtime template syntax + lines.push(' if (s.StartsWith("${") && s.EndsWith("}"))'); + lines.push(' {'); + lines.push(' var key = s[2..^1];'); + lines.push(' return ctx.TryGetValue(key, out var v) ? v : null;'); + lines.push(' }'); + lines.push(' return s;'); + lines.push(' }'); + lines.push(' if (template is Dictionary dict)'); + lines.push(' {'); + lines.push(' var resolved = new Dictionary();'); + lines.push(' foreach (var (key, value) in dict)'); + lines.push(' {'); + lines.push(' resolved[key] = ResolveTemplate(value, ctx);'); + lines.push(' }'); + lines.push(' return resolved;'); + lines.push(' }'); + lines.push(' if (template is List list)'); + lines.push(' {'); + lines.push(' var resolved = new List();'); + lines.push(' foreach (var item in list)'); + lines.push(' {'); + lines.push(' resolved.Add(ResolveTemplate(item, ctx));'); + lines.push(' }'); + lines.push(' return resolved;'); + lines.push(' }'); + lines.push(' return template;'); + lines.push(' }'); + lines.push(''); + lines.push( + ' private static string GetRequiredString(Dictionary ctx, string key)', + ); + lines.push(' {'); + lines.push(' if (!ctx.TryGetValue(key, out var value) || value is null)'); + lines.push(' {'); + lines.push( + ' throw new InvalidOperationException($"Missing required binding: {key}");', + ); + lines.push(' }'); + lines.push(' return value.ToString() ?? string.Empty;'); + lines.push(' }'); + lines.push(''); + lines.push( + ' private static void ApplyExtract(Dictionary ctx, object response, string fieldPath, string bind)', + ); + lines.push(' {'); + lines.push(' var root = JsonSerializer.SerializeToElement(response);'); + lines.push(' if (TryExtract(root, fieldPath, out var value))'); + lines.push(' {'); + lines.push(' ctx[bind] = value;'); + lines.push(' }'); + lines.push(' }'); + lines.push(''); + lines.push( + ' private static bool TryExtract(JsonElement element, string fieldPath, out object? value)', + ); + lines.push(' {'); + lines.push(' value = null;'); + lines.push(' var current = element;'); + lines.push(' foreach (var rawPart in fieldPath.Split("."))'); + lines.push(' {'); + lines.push(' var part = rawPart;'); + lines.push(' int? index = null;'); + lines.push(' if (part.EndsWith("[]"))'); + lines.push(' {'); + lines.push(' part = part[..^2];'); + lines.push(' index = 0;'); + lines.push(' }'); + lines.push(" var bracket = part.IndexOf('[');"); + lines.push(' if (bracket >= 0 && part.EndsWith("]"))'); + lines.push(' {'); + lines.push(' var name = part[..bracket];'); + lines.push(' var indexText = part[(bracket + 1)..^1];'); + lines.push(' if (int.TryParse(indexText, out var parsed)) index = parsed;'); + lines.push(' part = name;'); + lines.push(' }'); + lines.push(' if (!current.TryGetProperty(part, out current)) return false;'); + lines.push(' if (index is not null)'); + lines.push(' {'); + lines.push(' if (current.ValueKind != JsonValueKind.Array) return false;'); + lines.push(' if (current.GetArrayLength() == 0) return false;'); + lines.push(' current = current[index.Value];'); + lines.push(' }'); + lines.push(' }'); + lines.push(' value = current.ValueKind switch'); + lines.push(' {'); + lines.push(' JsonValueKind.String => current.GetString(),'); + lines.push(' JsonValueKind.Number => current.ToString(),'); + lines.push(' JsonValueKind.True => true,'); + lines.push(' JsonValueKind.False => false,'); + lines.push(' JsonValueKind.Null => null,'); + lines.push(' _ => current.ToString(),'); + lines.push(' };'); + lines.push(' return true;'); + lines.push(' }'); + lines.push(''); + lines.push( + ' private static DeploymentRequest BuildDeploymentRequest(object? template, Dictionary ctx)', + ); + lines.push(' {'); + lines.push( + ' var resolved = ResolveTemplate(template, ctx) as Dictionary;', + ); + lines.push(' var fields = resolved != null && resolved.TryGetValue("fields", out var f)'); + lines.push(' ? f as Dictionary'); + lines.push(' : null;'); + lines.push( + ' var files = resolved != null && resolved.TryGetValue("files", out var filesObj)', + ); + lines.push(' ? filesObj as Dictionary'); + lines.push(' : null;'); + lines.push(' var resources = new List();'); + lines.push(' if (files != null)'); + lines.push(' {'); + lines.push(' foreach (var (key, value) in files)'); + lines.push(' {'); + lines.push(' if (value is not string pathValue) continue;'); + lines.push( + ' var path = pathValue.StartsWith("@@FILE:") ? pathValue[7..] : pathValue;', + ); + lines.push(' var content = File.ReadAllBytes(path);'); + lines.push(' var fileName = Path.GetFileName(path);'); + lines.push( + ' resources.Add(new DeploymentResource(fileName, "application/octet-stream", content));', + ); + lines.push(' }'); + lines.push(' }'); + lines.push(' TenantId? tenantId = null;'); + lines.push(' if (fields != null && fields.TryGetValue("tenantId", out var tenant))'); + lines.push(' {'); + lines.push(' tenantId = tenant?.ToString();'); + lines.push(' }'); + lines.push( + ' return new DeploymentRequest { TenantId = tenantId, Resources = resources };', + ); + lines.push(' }'); + lines.push('}'); + lines.push(''); + return lines.join('\n'); +} + +function pascalCase(value: string): string { + return value + .replace(/[^A-Za-z0-9]+/g, ' ') + .trim() + .split(' ') + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(''); +} + +function resolveMethodName(opId: string, map: CsharpOperationMap): string | undefined { + const mapped = map[opId]?.[0]?.region; + if (mapped) return mapped; + return DEFAULT_METHOD_BY_OP_ID[opId]; +} + +function emitStep( + lines: string[], + opId: string, + methodName: string, + step: { + bodyTemplate?: unknown; + multipartTemplate?: unknown; + pathParams?: { name: string; var: string }[]; + extract?: { fieldPath: string; bind: string }[]; + }, +): void { + const responseVar = `${opId}Response`; + if (opId === 'createDeployment') { + lines.push(` var deploymentTemplate = ${renderTemplate(step.multipartTemplate)};`); + lines.push(' var deploymentRequest = BuildDeploymentRequest(deploymentTemplate, ctx);'); + lines.push(` var ${responseVar} = await client.${methodName}(deploymentRequest);`); + emitExtracts(lines, responseVar, step.extract); + return; + } + if (opId === 'createProcessInstance') { + lines.push(` var instanceTemplate = ${renderTemplate(step.bodyTemplate)};`); + lines.push( + ' var instanceRequest = FromTemplate(ResolveTemplate(instanceTemplate, ctx));', + ); + lines.push(` var ${responseVar} = await client.${methodName}(instanceRequest);`); + emitExtracts(lines, responseVar, step.extract); + return; + } + if (opId === 'searchProcessInstances') { + lines.push(` var searchTemplate = ${renderTemplate(step.bodyTemplate)};`); + lines.push( + ' var searchRequest = FromTemplate(ResolveTemplate(searchTemplate, ctx));', + ); + lines.push(` var ${responseVar} = await client.${methodName}(searchRequest);`); + emitExtracts(lines, responseVar, step.extract); + return; + } + if (opId === 'getProcessInstance') { + const pathVar = step.pathParams?.find((p) => p.name === 'processInstanceKey')?.var; + const processInstanceKey = pathVar ? `GetRequiredString(ctx, "${pathVar}")` : 'string.Empty'; + lines.push(` var ${responseVar} = await client.${methodName}(${processInstanceKey});`); + emitExtracts(lines, responseVar, step.extract); + return; + } + if (opId === 'activateJobs') { + lines.push(` var activationTemplate = ${renderTemplate(step.bodyTemplate)};`); + lines.push( + ' var activationRequest = FromTemplate(ResolveTemplate(activationTemplate, ctx));', + ); + lines.push(` var ${responseVar} = await client.${methodName}(activationRequest);`); + emitExtracts(lines, responseVar, step.extract); + return; + } + if (opId === 'searchJobs') { + lines.push(` var searchTemplate = ${renderTemplate(step.bodyTemplate)};`); + lines.push( + ' var searchRequest = FromTemplate(ResolveTemplate(searchTemplate, ctx));', + ); + lines.push(` var ${responseVar} = await client.${methodName}(searchRequest);`); + emitExtracts(lines, responseVar, step.extract); + return; + } + if (opId === 'completeJob') { + const pathVar = step.pathParams?.find((p) => p.name === 'jobKey')?.var; + const jobKey = pathVar ? `GetRequiredString(ctx, "${pathVar}")` : 'string.Empty'; + lines.push(` var completionTemplate = ${renderTemplate(step.bodyTemplate)};`); + lines.push( + ' var completionRequest = FromTemplate(ResolveTemplate(completionTemplate, ctx));', + ); + lines.push(` await client.${methodName}(${jobKey}, completionRequest);`); + return; + } + if (opId === 'failJob') { + const pathVar = step.pathParams?.find((p) => p.name === 'jobKey')?.var; + const jobKey = pathVar ? `GetRequiredString(ctx, "${pathVar}")` : 'string.Empty'; + lines.push(` var failureTemplate = ${renderTemplate(step.bodyTemplate)};`); + lines.push(' var resolvedFailure = ResolveTemplate(failureTemplate, ctx);'); + lines.push(' if (resolvedFailure is null)'); + lines.push(' {'); + lines.push(` await client.${methodName}(${jobKey});`); + lines.push(' }'); + lines.push(' else'); + lines.push(' {'); + lines.push(' var failureRequest = FromTemplate(resolvedFailure);'); + lines.push(` await client.${methodName}(${jobKey}, failureRequest);`); + lines.push(' }'); + return; + } + if (opId === 'cancelProcessInstance') { + const pathVar = step.pathParams?.find((p) => p.name === 'processInstanceKey')?.var; + const processInstanceKey = pathVar ? `GetRequiredString(ctx, "${pathVar}")` : 'string.Empty'; + lines.push(` var cancelTemplate = ${renderTemplate(step.bodyTemplate)};`); + lines.push( + ' var cancelRequest = FromTemplate(ResolveTemplate(cancelTemplate, ctx));', + ); + lines.push(` await client.${methodName}(${processInstanceKey}, cancelRequest);`); + return; + } + lines.push(` // TODO: Unsupported operation ${opId}`); +} + +function emitExtracts( + lines: string[], + responseVar: string, + extracts: { fieldPath: string; bind: string }[] | undefined, +): void { + if (!extracts || extracts.length === 0) return; + for (const ex of extracts) { + lines.push(` ApplyExtract(ctx, ${responseVar}, '${ex.fieldPath}', '${ex.bind}');`); + } +} + +function renderTemplate(value: unknown): string { + return renderValue(value); +} + +function renderValue(value: unknown): string { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'string') { + return JSON.stringify(value); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + const items = value.map((item) => renderValue(item)).join(', '); + return `new List { ${items} }`; + } + if (typeof value === 'object') { + // biome-ignore lint/plugin: runtime narrowing — typeof 'object' guard above ensures the cast is safe + const entries = Object.entries(value as Record) + .map(([key, v]) => `[${JSON.stringify(key)}] = ${renderValue(v)}`) + .join(', '); + return `new Dictionary { ${entries} }`; + } + return 'null'; +} + +const DEFAULT_METHOD_BY_OP_ID: Record = { + createDeployment: 'CreateDeploymentAsync', + createProcessInstance: 'CreateProcessInstanceAsync', + searchProcessInstances: 'SearchProcessInstancesAsync', + getProcessInstance: 'GetProcessInstanceAsync', + activateJobs: 'ActivateJobsAsync', + searchJobs: 'SearchJobsAsync', + completeJob: 'CompleteJobAsync', + failJob: 'FailJobAsync', + cancelProcessInstance: 'CancelProcessInstanceAsync', +}; diff --git a/materializer/src/csharp-sdk/materialize-support.ts b/materializer/src/csharp-sdk/materialize-support.ts new file mode 100644 index 00000000..86d98571 --- /dev/null +++ b/materializer/src/csharp-sdk/materialize-support.ts @@ -0,0 +1,149 @@ +/** + * Materialize C# SDK test support files into the generated output directory. + * + * Vendors a self-contained .NET project skeleton so the generated test suite + * is runnable standalone without any dependency on this generator project. + * + * Files materialized: + * - CamundaIntegrationTests.csproj — .NET 8 project file referencing the Camunda REST SDK + * - .env.example — environment variable template for local / SaaS configuration + * - README.md — how to build and run the generated suite + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +const CSPROJ = ` + + + net8.0 + enable + enable + + + + + + + + +`; + +const ENV_EXAMPLE = `# Camunda REST API connection settings +# Copy this file to .env and fill in the values. + +# Local / Self-Managed +CAMUNDA_BASE_URL=http://localhost:8080/v2/ + +# SaaS (OAuth2) — leave blank for local/self-managed +CAMUNDA_CLIENT_ID= +CAMUNDA_CLIENT_SECRET= +CAMUNDA_OAUTH_URL= +`; + +const README_MD = `# Generated C# SDK Integration Tests + +Auto-generated test suite targeting the Camunda REST API via +[Camunda.Orchestration.RestSdk](https://github.com/camunda/camunda-orchestration-rest-sdk-csharp). + +## Prerequisites + +- [.NET 8+](https://dotnet.microsoft.com/download) +- A running Camunda instance (local or SaaS) + +## Setup + +1. Copy **.env.example** to **.env** and fill in the connection details. +2. Restore dependencies: + + \`\`\`bash + dotnet restore + \`\`\` + +## Running the suite + +This is a .NET class library containing auto-generated test methods. The recommended approach is to create a separate xUnit/NUnit test project that references this library: + +### Setup test consumer project + +\`\`\`bash +# From the generated output directory +dotnet new xunit -n CamundaTests.xUnit +cd CamundaTests.xUnit +dotnet add reference ../CamundaIntegrationTests.csproj +\`\`\` + +### Write test wrapper + +\`\`\`csharp +// CamundaTests.xUnit/CamundaSuiteTests.cs +using Xunit; +using CamundaIntegrationTests; + +public class CamundaSuiteTests +{ + [Fact] + public async Task ActivateJobsSuite() => await GeneratedSuite.ActivateJobsAsync(); + + [Fact] + public async Task CreateProcessInstanceSuite() => await GeneratedSuite.CreateProcessInstanceAsync(); + // ... more test methods +} +\`\`\` + +### Run tests + +\`\`\`bash +cd CamundaTests.xUnit +dotnet test +\`\`\` + +### Alternative: Direct consumption + +You can also call the static methods directly from a custom console app or integration test runner without a formal test framework. + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| \`CAMUNDA_BASE_URL\` | \`http://localhost:8080/v2/\` | Camunda REST API base URL | +| \`CAMUNDA_CLIENT_ID\` | — | OAuth2 client ID (SaaS only) | +| \`CAMUNDA_CLIENT_SECRET\` | — | OAuth2 client secret (SaaS only) | +| \`CAMUNDA_OAUTH_URL\` | — | OAuth2 token endpoint URL (SaaS only) | +`; + +/** + * Materialize C# project scaffolding into `/` so the emitted C# SDK + * suite is self-contained and consumable via a test framework or custom runner. + * + * Idempotent: safe to call multiple times per emit run. + * + * @param outDir Directory to materialise into (created if missing). + * @param overwriteRoot When false, root scaffold files are only written if + * they do not already exist. Default: true. + */ +export async function materializeCsharpSupport( + outDir: string, + overwriteRoot = true, +): Promise { + await fs.mkdir(outDir, { recursive: true }); + // Ensure the csharp/ subdirectory that the emitter writes .cs files into exists. + await fs.mkdir(path.join(outDir, 'csharp'), { recursive: true }); + + const writeRoot = async (filename: string, content: string): Promise => { + const dest = path.join(outDir, filename); + if (!overwriteRoot) { + try { + await fs.access(dest); + return; // already exists — skip + } catch { + // does not exist — fall through to write + } + } + await fs.writeFile(dest, content, 'utf8'); + }; + + await writeRoot('CamundaIntegrationTests.csproj', CSPROJ); + await writeRoot('.env.example', ENV_EXAMPLE); + await writeRoot('README.md', README_MD); +} diff --git a/materializer/src/csharp-sdk/operation-map.ts b/materializer/src/csharp-sdk/operation-map.ts new file mode 100644 index 00000000..ab265061 --- /dev/null +++ b/materializer/src/csharp-sdk/operation-map.ts @@ -0,0 +1,55 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { CsharpOperationMap, CsharpOperationMapEntry } from './emitter.js'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isEntry(value: unknown): value is CsharpOperationMapEntry { + if (!isRecord(value)) return false; + const file = value.file; + const region = value.region; + const label = value.label; + return ( + (file === undefined || typeof file === 'string') && + (region === undefined || typeof region === 'string') && + (label === undefined || typeof label === 'string') + ); +} + +export async function loadCsharpOperationMap(baseDir: string): Promise { + const filePath = path.resolve(baseDir, '..', 'csharp-sdk', 'examples', 'operation-map.json'); + let text: string; + try { + text = await fs.readFile(filePath, 'utf8'); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + Reflect.get(error, 'code') === 'ENOENT' + ) { + return {}; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return {}; + } + if (!isRecord(parsed)) return {}; + + const result: CsharpOperationMap = {}; + for (const [opId, rawEntries] of Object.entries(parsed)) { + if (!Array.isArray(rawEntries)) continue; + const entries = rawEntries.filter(isEntry); + if (entries.length > 0) { + result[opId] = entries; + } + } + return result; +} diff --git a/materializer/src/index.ts b/materializer/src/index.ts index c10b1a83..7d13e12d 100644 --- a/materializer/src/index.ts +++ b/materializer/src/index.ts @@ -30,6 +30,8 @@ import { getEmitterRoleForOperation } from 'path-analyser/ontology/operationRole import type { EndpointScenarioCollection, GlobalContextSeed } from 'path-analyser/types'; import { parseCliArgs } from './cli-args.js'; import { buildCoverage, type CoverageResult } from './coverage.js'; +import { createCsharpEmitter } from './csharp-sdk/emitter.js'; +import { createJsSdkEmitter } from './js-sdk/emitter.js'; import { writeEmitted, writeScaffolded } from './orchestrator.js'; import { PlaywrightEmitter } from './playwright/emitter.js'; import { @@ -40,6 +42,7 @@ import { } from './playwright/materialize-support.js'; import { loadRoleBundlesForActiveConfig } from './playwright/roleRenderer.js'; import { emitTemplateSuites } from './playwright/templateEmitter.js'; +import { createPythonSdkEmitter } from './python-sdk/emitter.js'; // Built-in emitter registrations. RoleHookProviders are no longer // registered statically here: every provider lives next to its role @@ -50,6 +53,9 @@ import { emitTemplateSuites } from './playwright/templateEmitter.js'; // pulled OCA-specific knowledge into a package that is supposed to be // config-agnostic. registerEmitter(PlaywrightEmitter); +registerEmitter(createJsSdkEmitter()); +registerEmitter(createPythonSdkEmitter()); +registerEmitter(createCsharpEmitter({})); /** * Walk the active config's role bundles and register any role-hook @@ -346,7 +352,12 @@ async function run() { // version cannot survive into the current run. Without this, local // pre-push validation can diverge from CI (which always sees a fresh tree). // The support/ tree, README.md, and responses.json are re-materialised below. - await fs.rm(outDir, { recursive: true, force: true }); + // SDK emitters (js-sdk, python-sdk, csharp-sdk) share the outDir with + // playwright and must NOT wipe it — they add complementary file types + // (.test.ts, .spec.py, .cs) alongside the existing .spec.ts files. + if (emitter.id === 'playwright') { + await fs.rm(outDir, { recursive: true, force: true }); + } await fs.mkdir(outDir, { recursive: true }); // Lift 12 / #231: per-role template bundles for the active config's // Playwright emitter. Loaded from configs//codegen/playwright/roles/ @@ -604,28 +615,31 @@ async function run() { } // #331: persist the coverage artefact alongside the suites so it // is diffable in PRs and consumable by the L3 invariant in - // configs//regression-invariants.test.ts. Written for - // every emitter so the artefact's presence is independent of - // whether the current target shipped template suites this run. - await fs.writeFile( - path.join(outDir, 'coverage.json'), - `${JSON.stringify( - { - version: 1, - suppressedOpIds: [...coverage.suppressedOpIds].sort(), - entries: [...coverage.entries].sort((a, b) => - a.operationId === b.operationId - ? a.template === b.template - ? a.aboxRow.localeCompare(b.aboxRow) || a.stepKind.localeCompare(b.stepKind) - : a.template.localeCompare(b.template) - : a.operationId.localeCompare(b.operationId), - ), - }, - null, - 2, - )}\n`, - 'utf8', - ); + // configs//regression-invariants.test.ts. Only written by + // the Playwright emitter — SDK emitters (js-sdk, python-sdk, csharp-sdk) + // do not suppress via scenario-template coverage and must not overwrite + // the playwright-written artefact with an empty suppressedOpIds list. + if (emitter.id === PlaywrightEmitter.id) { + await fs.writeFile( + path.join(outDir, 'coverage.json'), + `${JSON.stringify( + { + version: 1, + suppressedOpIds: [...coverage.suppressedOpIds].sort(), + entries: [...coverage.entries].sort((a, b) => + a.operationId === b.operationId + ? a.template === b.template + ? a.aboxRow.localeCompare(b.aboxRow) || a.stepKind.localeCompare(b.stepKind) + : a.template.localeCompare(b.template) + : a.operationId.localeCompare(b.operationId), + ), + }, + null, + 2, + )}\n`, + 'utf8', + ); + } console.log( `Generated test suites for ${count} endpoints (+${variantCount} variant suites, +${lifecycleCount} lifecycle suites, -${suppressedCount} suppressed by scenario-template coverage) in ${outDir} (target: ${emitter.id})`, ); diff --git a/materializer/src/js-sdk/README.md b/materializer/src/js-sdk/README.md new file mode 100644 index 00000000..834f8ca4 --- /dev/null +++ b/materializer/src/js-sdk/README.md @@ -0,0 +1,57 @@ +# JS SDK Emitter (`--target=js-sdk`) + +Lowers `EndpointScenarioCollection` graphs onto the +[`@camunda8/orchestration-cluster-api`](https://github.com/camunda/orchestration-cluster-api-js) +JavaScript SDK and emits self-contained **Vitest** test suites. + +## Generated file layout + +``` +/ + .feature.test.ts # Feature scenarios + .integration.test.ts + .variant.test.ts + support/ + seeding.ts + seed-rules.json + package.json + tsconfig.json + vitest.config.ts + .env.example + README.md +``` + +## Running the generated suite + +```bash +cd +npm install +npm test +``` + +## Prerequisites + +The operation-map file must be fetched before generation: + +```bash +npm run fetch-js-sdk-map +``` + +This performs a git sparse clone of `camunda/orchestration-cluster-api-js` and +writes `examples/operation-map.json` to `spec/js-sdk/operation-map.json`. +Requires `git` on PATH. Without it, the emitter falls back to identity mapping +(operationId unchanged). + +⚠️ **Warning**: Fallback identity mapping may not work for all operations — +some SDK methods have different names than their operationIds. Run +`npm run fetch-js-sdk-map` to ensure correct method names in generated tests. + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `CAMUNDA_BASE_URL` | `http://localhost:8080` | Camunda REST API base URL | +| `CAMUNDA_CLIENT_ID` | — | OAuth2 client ID (SaaS) | +| `CAMUNDA_CLIENT_SECRET` | — | OAuth2 client secret (SaaS) | +| `CAMUNDA_OAUTH_URL` | — | OAuth2 token endpoint (SaaS) | +| `JS_SDK_REF` | `main` | Branch, tag, or SHA of `camunda/orchestration-cluster-api-js` to fetch the operation map from | diff --git a/materializer/src/js-sdk/emitter.ts b/materializer/src/js-sdk/emitter.ts new file mode 100644 index 00000000..92418ca6 --- /dev/null +++ b/materializer/src/js-sdk/emitter.ts @@ -0,0 +1,308 @@ +import type { EmitContext, EmittedFile, EmitterStrategy } from '@camunda8/emitter-sdk'; +import { assertSafeGlobalContextSeeds } from 'path-analyser/ontology/loader'; +import type { + EndpointScenario, + EndpointScenarioCollection, + GlobalContextSeed, + RequestStep, +} from 'path-analyser/types'; +import { FallbackMappingSource, type SdkMappingSource } from './sdk-mapping.js'; + +/** + * Returns the file name a JS SDK scenario collection lowers to. + * Uses `.test.ts` suffix (Vitest convention) instead of `.spec.ts` (Playwright). + */ +export function jsSdkSuiteFileName( + collection: EndpointScenarioCollection, + mode: 'feature' | 'integration' | 'variant', +): string { + return `${collection.endpoint.operationId}.${mode}.test.ts`; +} + +/** + * Pure rendering entry point — returns the Vitest suite source as a string. + * Used by `JsSdkEmitter` and by callers that want the source without writing. + */ +export function renderJsSdkSuite( + collection: EndpointScenarioCollection, + mapping: SdkMappingSource, + opts: { + suiteName?: string; + mode?: 'feature' | 'integration' | 'variant'; + globalContextSeeds?: readonly GlobalContextSeed[]; + }, +): string { + return buildSuiteSource(collection, mapping, opts); +} + +/** + * Factory: create a `JsSdkEmitter` backed by the given `SdkMappingSource`. + * + * When no source is supplied, `FallbackMappingSource` is used, which returns + * the operationId unchanged (already camelCase in the Camunda REST API). + */ +export function createJsSdkEmitter(mapping?: SdkMappingSource): EmitterStrategy { + const source = mapping ?? new FallbackMappingSource(); + return { + id: 'js-sdk', + name: 'JavaScript SDK (@camunda8/orchestration-cluster-api)', + supportedConfigs: ['*'], + async emit(collection: EndpointScenarioCollection, ctx: EmitContext): Promise { + const content = renderJsSdkSuite(collection, source, { + suiteName: ctx.suiteName, + mode: ctx.mode, + globalContextSeeds: ctx.globalContextSeeds, + }); + return [ + { + relativePath: jsSdkSuiteFileName(collection, ctx.mode), + content, + }, + ]; + }, + }; +} + +// --------------------------------------------------------------------------- +// Internal rendering +// --------------------------------------------------------------------------- + +function buildSuiteSource( + collection: EndpointScenarioCollection, + mapping: SdkMappingSource, + opts: { + suiteName?: string; + mode?: 'feature' | 'integration' | 'variant'; + globalContextSeeds?: readonly GlobalContextSeed[]; + }, +): string { + // Boundary safety re-check: same defence-in-depth as PlaywrightEmitter. + if (opts.globalContextSeeds !== undefined) { + assertSafeGlobalContextSeeds(opts.globalContextSeeds); + } + + const lines: string[] = []; + const suiteName = opts.suiteName || collection.endpoint.operationId; + + lines.push("import { describe, test } from 'vitest';"); + lines.push("import createCamundaClient from '@camunda8/orchestration-cluster-api';"); + lines.push("import { extractInto, seedBinding } from './support/seeding';"); + lines.push(''); + // Single shared client for all tests in this suite (zero-config → reads + // CAMUNDA_* from process.env; defaults to http://localhost:8080 when absent). + lines.push('const client = createCamundaClient();'); + lines.push(''); + lines.push(`describe('${suiteName}', () => {`); + + const seeds = opts.globalContextSeeds ?? []; + for (const scenario of collection.scenarios) { + lines.push(renderScenarioTest(scenario, mapping, seeds)); + } + + lines.push('});'); + return lines.join('\n'); +} + +function renderScenarioTest( + s: EndpointScenario, + mapping: SdkMappingSource, + globalContextSeeds: readonly GlobalContextSeed[], +): string { + const title = `${s.id} - ${escapeQuotes(s.name || 'scenario')}`; + const body: string[] = []; + body.push(` test('${title}', async () => {`); + + if (s.description) { + const desc = String(s.description).trim(); + const wrapped: string[] = []; + const words = desc.split(/\s+/); + let line = ''; + for (const w of words) { + if (`${line} ${w}`.trim().length > 100) { + wrapped.push(line.trim()); + line = w; + } else { + line += (line ? ' ' : '') + w; + } + } + if (line) wrapped.push(line.trim()); + for (const l of wrapped) body.push(` // ${l}`); + } + + body.push(` const ctx: Record = {};`); + + // Collect extraction target variable names across all steps + const extractionVars = new Set(); + if (s.requestPlan) { + for (const step of s.requestPlan) { + if (step.extract) { + for (const ex of step.extract) extractionVars.add(ex.bind); + } + } + } + + // Seed scenario bindings + if (s.bindings && Object.keys(s.bindings).length) { + body.push(' // Seed scenario bindings'); + const templateVars = new Set(); + function collectVarsFromTemplate(obj: unknown) { + if (!obj || typeof obj !== 'object') return; + for (const val of Object.values(obj)) { + if (typeof val === 'string') { + const m = val.match(/^\$\{([^}]+)\}$/); + if (m) templateVars.add(m[1]); + } else if (typeof val === 'object') collectVarsFromTemplate(val); + } + } + if (s.requestPlan) { + for (const step of s.requestPlan) { + if (step.bodyTemplate) collectVarsFromTemplate(step.bodyTemplate); + // Path params are consumed via ctx just like body template vars. + if (step.pathParams) { + for (const p of step.pathParams) templateVars.add(p.var); + } + } + } + for (const [k, v] of Object.entries(s.bindings)) { + if (v === '__PENDING__') { + if (!templateVars.has(k)) continue; + if (extractionVars.has(k)) continue; + body.push(` if (ctx['${k}'] === undefined) { ctx['${k}'] = seedBinding('${k}'); }`); + continue; + } + if (extractionVars.has(k)) continue; + body.push(` ctx['${k}'] = ${JSON.stringify(v)};`); + } + } + + // Universal-seed prologue (same logic as PlaywrightEmitter — see its + // detailed comment for the nullish-coalescing rationale). + for (const seed of globalContextSeeds) { + body.push( + ` ctx['${seed.binding}'] = ctx['${seed.binding}'] ?? seedBinding('${seed.seedRule}');`, + ); + } + + if (!s.requestPlan) { + body.push(' // No request plan available'); + body.push(' });'); + return body.join('\n'); + } + + const requestPlan = s.requestPlan; + requestPlan.forEach((step: RequestStep, idx: number) => { + const method = mapping.resolveMethod(step.operationId); + const varName = `result${idx + 1}`; + + // Hard-fail on multipart: the SDK helper for file uploads uses a + // different signature (e.g. deployResourcesFromFiles takes file paths, + // not a generic multipart template). Surface the gap rather than + // silently emitting wrong call shapes. + if (step.bodyKind === 'multipart') { + throw new Error( + `JS SDK emitter: operationId '${step.operationId}' has a multipart body. ` + + `The SDK helper '${method}' uses a different signature. ` + + `This scenario cannot be lowered automatically; handle it manually or ` + + `implement a dedicated multipart adapter for this operation.`, + ); + } + + body.push(` // Step ${idx + 1}: ${step.operationId}`); + body.push(` {`); + + // Build the call argument object by merging path params and body. + const argParts: string[] = []; + + // Path parameters: contribute their values to the args object. + if (step.pathParams?.length) { + for (const p of step.pathParams) { + argParts.push(` ${p.name}: ctx['${p.var}'],`); + } + } + + // JSON body: inline the resolved template fields. + if (step.bodyKind === 'json' && step.bodyTemplate) { + const bodyJson = JSON.stringify(step.bodyTemplate, null, 6).replace( + /"\\?\$\{([^}]+)\}"/g, + (_, v) => `ctx["${v}"]`, + ); + // Splice the inner fields of the body object into argParts (if it is + // a plain object). If the body is not a plain object we fall back to + // spreading it. + if ( + typeof step.bodyTemplate === 'object' && + step.bodyTemplate !== null && + !Array.isArray(step.bodyTemplate) + ) { + // Strip the outer braces and indent one level. + const inner = bodyJson.replace(/^\{/, '').replace(/\}$/, '').trimEnd(); + argParts.push(inner); + } else { + // Non-object body (array, primitive) — spread via Object.assign downstream. + argParts.push(` ...${bodyJson},`); + } + } + + if (argParts.length > 0) { + body.push(` const args${idx + 1} = {`); + body.push(argParts.join('\n')); + body.push(` };`); + body.push(` const ${varName} = await client.${method}(args${idx + 1});`); + } else { + // No args: operation takes no parameters (e.g. getTopology). + body.push(` const ${varName} = await client.${method}();`); + } + + // The SDK throws on non-2xx so there is no explicit status assertion. + // For the final step we add a basic defined-check as a smoke test. + const isFinal = idx === requestPlan.length - 1; + if (isFinal) { + body.push(` // SDK throws on non-${step.expect.status}; reaching here means success`); + } + + // Extraction: pull fields from the typed SDK response into ctx. + // Unlike PlaywrightEmitter, there is no .json() call — the response is + // already a typed object. + if (step.extract?.length) { + for (const ex of step.extract) { + const optAcc = toOptionalAccessor(ex.fieldPath); + body.push(` extractInto(ctx, '${ex.bind}', ${varName}${optAcc});`); + } + } + + body.push(' }'); + }); + + body.push(' });'); + return body.join('\n'); +} + +// --------------------------------------------------------------------------- +// Utilities (mirrors of PlaywrightEmitter utilities) +// --------------------------------------------------------------------------- + +function escapeQuotes(s: string): string { + return s.replace(/'/g, "\\'"); +} + +/** + * Converts a dotted field path (possibly with array notation) into a + * TypeScript optional-chaining accessor expression. + * + * Examples: + * "key" → ".key" + * "metadata.processInstanceKey" → "?.metadata?.processInstanceKey" + * "items[0].key" → "?.items?.[0]?.key" + */ +function toOptionalAccessor(fieldPath: string): string { + if (!fieldPath.includes('.') && !fieldPath.includes('[')) { + return `.${fieldPath}`; + } + const parts = fieldPath.split(/(?=\[)|[.]/).filter(Boolean); + return parts + .map((p) => { + if (p.startsWith('[')) return `?.[${p.slice(1, -1)}]`; + return `?.${p}`; + }) + .join(''); +} diff --git a/materializer/src/js-sdk/materialize-support.ts b/materializer/src/js-sdk/materialize-support.ts new file mode 100644 index 00000000..cd1f2f18 --- /dev/null +++ b/materializer/src/js-sdk/materialize-support.ts @@ -0,0 +1,106 @@ +// --------------------------------------------------------------------------- +// Vendors the JS SDK runtime support files AND project scaffolding into an +// emitted test suite so the suite is runnable in place: +// +// cd +// npm install +// npm test +// +// Two sets of files are materialised: +// * support/ — runtime helpers (seeding.ts, seed-rules.json). +// Sources: path-analyser/src/codegen/support/ (shared with the +// Playwright emitter). Staged to +// dist/src/codegen/js-sdk/support-templates/ at build time. +// * project root — package.json, tsconfig.json, vitest.config.ts, +// .env.example, README.md. +// Sources: path-analyser/src/codegen/js-sdk/project-templates/. +// Staged to dist/src/codegen/js-sdk/project-templates/ at build time. +// --------------------------------------------------------------------------- +import { existsSync, promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Support files vendored into `/support/`. + * + * The JS SDK emitter only needs the seeding utilities — it does not use + * Playwright fixtures, the HTTP recorder, or the `awaitEventually` helper + * (the SDK has built-in eventual-consistency polling). + */ +export const SDK_SUPPORT_TEMPLATE_FILES = ['seeding.ts', 'seed-rules.json'] as const; + +/** Files copied directly into `/` (project root scaffolding). */ +export const SDK_PROJECT_TEMPLATE_FILES = [ + 'package.json', + 'tsconfig.json', + 'vitest.config.ts', + '.env.example', + 'README.md', +] as const; + +/** Subdirectory created under the emitter's outDir to hold vendored helpers. */ +export const SDK_SUPPORT_DIR_NAME = 'support'; + +function defaultSupportTemplatesDir(): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + if (here.includes(`${path.sep}dist${path.sep}`)) { + return path.join(here, 'support-templates'); + } + // Source mode: walk up from src/codegen/js-sdk/ to src/codegen/support/. + return path.resolve(here, '..', 'support'); +} + +function defaultProjectTemplatesDir(): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + if (here.includes(`${path.sep}dist${path.sep}`)) { + return path.join(here, 'project-templates'); + } + // Source mode: templates are co-located in src/codegen/js-sdk/project-templates/. + return path.resolve(here, 'project-templates'); +} + +/** + * Copy the runtime support helpers AND project-root scaffolding into + * `/` so the emitted JS SDK suite is self-contained and runnable + * in place (`cd && npm install && npm test`). + * + * Idempotent: safe to call multiple times per emit run. + * + * @param outDir Directory to materialise into (created if missing). + * @param supportTemplatesDir Optional override for the support-templates + * source directory (seeding.ts, seed-rules.json). + * Production callers should omit this. + * @param projectTemplatesDir Optional override for the project-root templates + * (package.json, vitest.config.ts, …). + * Production callers should omit this. + * @param overwriteRoot When false, root scaffolding files are only + * written if they don't already exist. Support + * files are always overwritten regardless. + * Default: true. + * @returns Path to the support directory under `outDir`. + */ +export async function materializeSdkSupport( + outDir: string, + supportTemplatesDir?: string, + projectTemplatesDir?: string, + overwriteRoot: boolean = true, +): Promise { + const srcDir = supportTemplatesDir ?? defaultSupportTemplatesDir(); + const destDir = path.join(outDir, SDK_SUPPORT_DIR_NAME); + await fs.mkdir(destDir, { recursive: true }); + + // Always overwrite support/ — these are part of the generator's contract. + for (const name of SDK_SUPPORT_TEMPLATE_FILES) { + await fs.copyFile(path.join(srcDir, name), path.join(destDir, name)); + } + + // Project root scaffolding: overwrite by default; opt-out preserves edits. + const projSrcDir = projectTemplatesDir ?? defaultProjectTemplatesDir(); + for (const name of SDK_PROJECT_TEMPLATE_FILES) { + const dest = path.join(outDir, name); + if (!overwriteRoot && existsSync(dest)) continue; + await fs.copyFile(path.join(projSrcDir, name), dest); + } + + return destDir; +} diff --git a/materializer/src/js-sdk/project-templates/.env.example b/materializer/src/js-sdk/project-templates/.env.example new file mode 100644 index 00000000..1c566060 --- /dev/null +++ b/materializer/src/js-sdk/project-templates/.env.example @@ -0,0 +1,16 @@ +# Local (unauthenticated) — Camunda 8 Run defaults +# The SDK reads CAMUNDA_* from the environment. Uncomment and fill in for +# your deployment. When all vars are absent the SDK connects to localhost:8080. + +# Local cluster address (default: http://localhost:8080) +# CAMUNDA_REST_ADDRESS=http://localhost:8080 + +# OAuth (SaaS / Self-Managed with Identity) +# CAMUNDA_AUTH_STRATEGY=OAUTH +# CAMUNDA_CLIENT_ID=your-client-id +# CAMUNDA_CLIENT_SECRET=your-client-secret +# CAMUNDA_OAUTH_URL=https://login.cloud.camunda.io/oauth/token +# CAMUNDA_TOKEN_AUDIENCE=zeebe.camunda.io + +# Default tenant (leave as for single-tenant mode) +# CAMUNDA_DEFAULT_TENANT_ID= diff --git a/materializer/src/js-sdk/project-templates/README.md b/materializer/src/js-sdk/project-templates/README.md new file mode 100644 index 00000000..e6455b65 --- /dev/null +++ b/materializer/src/js-sdk/project-templates/README.md @@ -0,0 +1,40 @@ +# Generated Camunda JS SDK Integration Suite + +This directory contains a self-contained Vitest test suite generated by the +[api-test-generator](https://github.com/camunda/api-test-generator) for the +`js-sdk` target. + +## Running + +```bash +npm install +# Copy .env.example to .env and fill in your cluster credentials (optional for local) +npm test +``` + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `CAMUNDA_REST_ADDRESS` | `http://localhost:8080` | Cluster REST endpoint | +| `CAMUNDA_AUTH_STRATEGY` | `NONE` | `NONE`, `BASIC`, or `OAUTH` | +| `CAMUNDA_CLIENT_ID` | — | OAuth client ID (required for OAUTH) | +| `CAMUNDA_CLIENT_SECRET` | — | OAuth client secret (required for OAUTH) | +| `CAMUNDA_OAUTH_URL` | — | OAuth token endpoint (required for OAUTH) | +| `CAMUNDA_DEFAULT_TENANT_ID` | `` | Default tenant override | + +For the full list of supported environment variables see the +[@camunda8/orchestration-cluster-api README](https://github.com/camunda/orchestration-cluster-api-js). + +## Structure + +``` +/ + *.feature.test.ts # generated test files (one per endpoint) + support/ + seeding.ts # deterministic / random value generators + seed-rules.json # value-generation rules + vitest.config.ts + package.json + tsconfig.json +``` diff --git a/materializer/src/js-sdk/project-templates/package.json b/materializer/src/js-sdk/project-templates/package.json new file mode 100644 index 00000000..71e3fbaa --- /dev/null +++ b/materializer/src/js-sdk/project-templates/package.json @@ -0,0 +1,18 @@ +{ + "name": "camunda-js-sdk-suite", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Generated Vitest integration suite for the Camunda REST API (JS SDK target), produced by the api-test-generator path-analyser.", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@camunda8/orchestration-cluster-api": "latest" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.5.4", + "vitest": "^2.0.0" + } +} diff --git a/materializer/src/js-sdk/project-templates/tsconfig.json b/materializer/src/js-sdk/project-templates/tsconfig.json new file mode 100644 index 00000000..844e9bbf --- /dev/null +++ b/materializer/src/js-sdk/project-templates/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/materializer/src/js-sdk/project-templates/vitest.config.ts b/materializer/src/js-sdk/project-templates/vitest.config.ts new file mode 100644 index 00000000..85950424 --- /dev/null +++ b/materializer/src/js-sdk/project-templates/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['**/*.test.ts'], + // Each test file gets its own suite run sequentially — the generated + // tests mutate live cluster state and are order-sensitive within a + // file. Cross-file parallelism is safe. + pool: 'forks', + testTimeout: 60_000, + hookTimeout: 30_000, + }, +}); diff --git a/materializer/src/js-sdk/sdk-mapping.ts b/materializer/src/js-sdk/sdk-mapping.ts new file mode 100644 index 00000000..c3d36249 --- /dev/null +++ b/materializer/src/js-sdk/sdk-mapping.ts @@ -0,0 +1,121 @@ +/** + * SDK mapping strategy for the JS SDK emitter. + * + * `SdkMappingSource` is the shared interface for resolving operationIds to + * method symbols. The JS emitter uses `OperationMapJsonSource` backed by + * `examples/operation-map.json` from the `camunda/orchestration-cluster-api-js` + * repository (fetched at generation time via `npm run fetch-js-sdk-map`). + * + * Future Python and C# emitters can implement `SdkMappingSource` with their + * own lookup tables. + */ + +export interface OperationMapEntry { + file: string; + region: string; + label: string; +} + +export type OperationMap = Record; + +/** + * Resolves an operationId to the preferred SDK method symbol. + * + * Implementations must be stateless and side-effect-free after construction. + */ +export interface SdkMappingSource { + /** + * Returns the preferred SDK method symbol for the given operationId. + * + * Strategy (Option C from issue #8): + * - Look up `operation-map.json[operationId][0].region` + * - Convert the PascalCase region to camelCase to get the method name + * - If no mapping exists, return `operationId` directly (already camelCase) + */ + resolveMethod(operationId: string): string; + + /** Returns all operationIds known to this mapping source. */ + knownOperationIds(): string[]; +} + +/** + * Converts a PascalCase region string to the camelCase SDK method name. + * + * Examples: + * "DeployResourcesFromFiles" → "deployResourcesFromFiles" + * "CreateProcessInstanceById" → "createProcessInstanceById" + * "GetTopology" → "getTopology" + */ +export function regionToCamelCase(region: string): string { + if (!region) return region; + return region.charAt(0).toLowerCase() + region.slice(1); +} + +/** + * Implements `SdkMappingSource` using `examples/operation-map.json` from + * `camunda/orchestration-cluster-api-js`. + * + * Each entry maps an operationId to one or more SDK examples. The first + * entry's `region` field is the preferred method symbol (PascalCase). + * This class converts it to camelCase. + */ +export class OperationMapJsonSource implements SdkMappingSource { + private readonly map: OperationMap; + + constructor(map: OperationMap) { + this.map = map; + } + + resolveMethod(operationId: string): string { + const entries = this.map[operationId]; + if (entries && entries.length > 0 && entries[0].region) { + return regionToCamelCase(entries[0].region); + } + // Fallback: operationId is already camelCase in the Camunda REST API. + return operationId; + } + + knownOperationIds(): string[] { + return Object.keys(this.map); + } + + /** + * Constructs an `OperationMapJsonSource` from raw JSON text. + * + * Throws `SyntaxError` on malformed JSON — callers should handle this + * and fall back to an empty source or propagate the error. + */ + static fromJson(json: string): OperationMapJsonSource { + // biome-ignore lint/plugin: runtime contract boundary for parsed JSON from a fetched file + const parsed = JSON.parse(json) as OperationMap; + return new OperationMapJsonSource(parsed); + } +} + +/** + * A no-op `SdkMappingSource` that always falls back to the operationId. + * + * Used when `operation-map.json` has not been fetched yet (e.g. the user + * has not run `npm run fetch-js-sdk-map`). The emitted code will still work + * since operationIds already match the raw SDK method names. + * + * ⚠️ Warning: Emits a console warning when instantiated, as fallback mapping + * may not work for all operations — some SDK methods have different names. + */ +export class FallbackMappingSource implements SdkMappingSource { + constructor() { + // Warn once at emitter creation time + console.warn( + '[js-sdk-emitter] Using fallback operation-map (operationId unchanged). ' + + 'Some SDK methods may have different names. Run `npm run fetch-js-sdk-map` to ensure correct method names.', + ); + } + + resolveMethod(operationId: string): string { + return operationId; + } + + knownOperationIds(): string[] { + return []; + } +} diff --git a/materializer/src/playwright/materialize-support.ts b/materializer/src/playwright/materialize-support.ts index 1a056fb2..1d2c1e7b 100644 --- a/materializer/src/playwright/materialize-support.ts +++ b/materializer/src/playwright/materialize-support.ts @@ -549,7 +549,7 @@ export async function materializeResponseSchemas( `--specFile=${resolvedSpec}`, `--outputDir=${targetDir}`, ], - { stdio: 'inherit' }, + { stdio: 'inherit', shell: true }, ); if (result.status !== 0) { throw new Error( diff --git a/materializer/src/python-sdk/README.md b/materializer/src/python-sdk/README.md new file mode 100644 index 00000000..cef89e7b --- /dev/null +++ b/materializer/src/python-sdk/README.md @@ -0,0 +1,349 @@ +# Python SDK Emitter + +Generates async pytest test suites for the Camunda REST API using the `camunda-orchestration-sdk` Python package. + +## Overview + +The Python SDK emitter lowers an `EndpointScenarioCollection` (from the planner) into async pytest tests that invoke the `CamundaAsyncClient` from the Python SDK. + +- **Emitter ID**: `python-sdk` +- **Output format**: `.py` async pytest test modules +- **Test framework**: pytest + pytest-asyncio +- **Client**: `CamundaAsyncClient` (async, recommended) +- **Assertion strategy**: SDK raises typed exceptions on non-2xx; plain `assert result is not None` smoke test + +## Usage + +### Generate Python tests for all endpoints + +```bash +npm run codegen:python-sdk:all +``` + +### Generate Python tests for a single endpoint + +```bash +npm run codegen:python-sdk -- activateJobs +``` + +## Environment Variables + +### `PYTHON_SDK_REF` (for fetch-python-sdk-map) + +Controls which commit of `camunda/orchestration-cluster-api-python` is fetched. + +```bash +# Fetch from main (default) +npm run fetch-python-sdk-map + +# Fetch from specific commit SHA +PYTHON_SDK_REF=abc123def456 npm run fetch-python-sdk-map + +# As part of the full pipeline +npm run pipeline +``` + +## Emitted Test Structure + +### Example: activateJobs scenario + +```python +# test/activate_jobs.python_sdk.spec.py +# Test suite for activateJobs +# This file is auto-generated. Do not edit. + +from typing import Any +import pytest +from camunda.client import CamundaAsyncClient +from helper import extract_into, seedBinding + +@pytest.mark.asyncio +async def test_sc_activate_jobs_simple(client: CamundaAsyncClient) -> None: + """Activates jobs of a given type and extracts the first job key""" + + ctx: dict[str, Any] = {} + + # Seed scenario bindings + ctx['workerType'] = 'MyWorkerType' + + # Seed runtime-generated bindings + if 'workerType' not in ctx: + ctx['workerType'] = seedBinding('workerType') + + # Step 1: activateJobs + request_body = { + 'type': ctx['workerType'], + 'maxJobsToActivate': 1, + 'timeout': 30000, + } + + result = await client.activate_jobs( + data=ActivateJobsRequest.from_dict(request_body) + ) + + assert result is not None, 'activateJobs must return a response' + + # Extract response fields + extract_into(ctx, 'jobKey', result['jobs'][0]['key']) +``` + +## Materialized Support Files + +The emitter vendors the following Python files into the generated suite directory: + +- **conftest.py** — pytest configuration and `CamundaAsyncClient` session fixture +- **helper.py** — `extract_into()` and `seedBinding()` test helpers +- **requirements.txt** — dependencies (camunda-orchestration-sdk, pytest, pytest-asyncio) +- **pytest.ini** — pytest config with `asyncio_mode = auto` + +### conftest.py: Client Fixture + +Supports both local (unauthenticated) and SaaS (OAuth2) configurations via environment variables: + +**Local (unauthenticated):** +```bash +export CAMUNDA_BASE_URL=http://localhost:8080 +pytest test_*.py +``` + +**SaaS (OAuth2):** +```bash +export CAMUNDA_BASE_URL=https://.camunda.cloud +export CAMUNDA_CLIENT_ID= +export CAMUNDA_CLIENT_SECRET= +export CAMUNDA_OAUTH_URL=https://.auth.camunda.cloud +pytest test_*.py +``` + +The `client` fixture is session-scoped and injected into every test automatically. + +### helper.py: Test Helpers + +**`extract_into(ctx, bind_name, value)`** — Extract a response field into the test context. +- Preserves existing bindings (skips assignment if value is None). +- Called after each step to capture response fields for downstream steps. + +**`seedBinding(bind_name, default_value=None)`** — Seed a random or default value. +- Generates UUIDs for identifier-type bindings when no default is provided. +- Called during scenario setup to populate undefined bindings. + +## SDK Operation Mapping + +The emitter translates Camunda `operationId` (camelCase) to Python SDK method names (snake_case): + +- `activateJobs` → `client.activate_jobs()` +- `createDeployment` → `client.create_deployment()` +- `deleteProcessDefinition` → `client.delete_process_definition()` + +Resolution order: +1. Check `spec/python-sdk/operation-map.json` (fetched from SDK repo via `fetch-python-sdk-map`) +2. Fall back to `camelToSnake()` conversion if not found + +## Supported Scenario Shapes + +### ✅ Supported + +- **Single-step scenarios** — one `client.()` call per test +- **Multi-step chains** — request → extract → next request +- **JSON request bodies** — converted via `.from_dict(body_dict)` +- **Response field extraction** — via `extract_into()` helper +- **Seed bindings** — literal values or generated UUIDs + +### ❌ Unsupported (Hard-fail at generation time) + +- **Multipart request bodies** — `bodyKind === 'multipart'` + - Python SDK integration does not support file uploads; use HTTP/Playwright suites instead +- **Custom validation logic** — jsonschema, pydantic, etc. + - SDK raises typed exceptions on non-2xx; plain `assert` statements sufficient for smoke tests + +## Test Execution + +### Install dependencies + +```bash +cd +pip install -r requirements.txt +``` + +### Run all tests + +```bash +pytest test_*.py -v +``` + +### Run a single test + +```bash +pytest test_activate_jobs.python_sdk.spec.py::test_sc_activate_jobs_simple -v +``` + +### Run with live broker (docker-compose) + +```bash +# Terminal 1: start Camunda +docker-compose up -d + +# Terminal 2: run tests +pytest test_*.py -v --tb=short + +# Terminal 3: cleanup +docker-compose down +``` + +## Architecture & Purity + +The Python SDK emitter is **pure**: it accepts a scenario collection and context, and returns an in-memory list of `EmittedFile` objects. No filesystem or network I/O occurs during emission. The orchestrator (`path-analyser/src/codegen/orchestrator.ts`) handles directory creation and file writes. + +### EmitterFactory Pattern + +The emitter is created by `createPythonSdkEmitter()`, which accepts an optional `OperationMapJsonSource`. This allows: + +- **Production use**: Map is fetched from `spec/python-sdk/operation-map.json` and passed to the factory +- **Unit tests**: Tests use a default fallback or mock mapping without requiring the SDK repo + +### Determinism + +The emitter produces deterministic output: identical input always yields identical output. This is critical for: + +- Regression testing (Layer-3 invariants) +- Build cache validation +- Snapshot-based testing + +## Limitations & Roadmap + +### Current Limitations + +- **No request-validation (negative testing)** — HTTP-only feature; Python SDK tests are smoke tests +- **No custom assertions** — plain `assert result is not None`; SDK exceptions are the primary assertion mechanism +- **No type stubs** — model class names inferred heuristically from operationId (e.g., `ActivateJobsRequest`) + +### Future Enhancements + +- Auto-generate type stubs from OpenAPI spec +- Optional Pydantic schema validation +- Support for discriminated union (oneOf/anyOf) response shapes +- Multi-step orchestration with explicit context binding visualization + +**SaaS (OAuth2):** +```bash +export CAMUNDA_BASE_URL=https://.camunda.cloud +export CAMUNDA_CLIENT_ID= +export CAMUNDA_CLIENT_SECRET= +export CAMUNDA_OAUTH_URL=https://.auth.camunda.cloud +pytest test_*.py +``` + +## Operation Mapping + +The emitter resolves `operationId` (camelCase) from the OpenAPI spec to Python method names (snake_case) via the operation-map loaded from the Python SDK: + +- `activateJobs` → `activate_jobs` +- `createDeployment` → `create_deployment` +- `listProcessDefinitions` → `list_process_definitions` + +If the operation-map is unavailable, the emitter falls back to simple camelCase → snake_case conversion. + +## Request Bodies + +Request bodies are instantiated using the `from_dict()` class method: + +```python +# For single-body operations: +result = await client.create_deployment( + data=CreateDeploymentRequest.from_dict(request_body) +) + +# For oneOf/anyOf bodies, from_dict() selects the discriminant variant internally +result = await client.start_process_instance( + data=StartProcessInstanceRequest.from_dict(request_body) +) +``` + +## Response Extraction + +Response fields are extracted into the test context dict using the `extract_into()` helper: + +```python +extract_into(ctx, 'jobKey', result.jobs[0].key) +extract_into(ctx, 'processInstanceKey', result.processInstanceKey) +``` + +The helper preserves seeded bindings — if a field is `None` or undefined, the existing binding is not overwritten. + +## Error Handling + +The Python SDK raises typed exceptions for non-2xx responses: + +- `BadRequestError` (400) +- `UnauthorizedError` (401) +- `NotFoundError` (404) +- `ConflictError` (409) +- And others per the SDK contract + +The emitter does **not** emit explicit status assertions. Reaching the next line confirms success; an exception proves failure: + +```python +result = await client.activate_jobs(data=...) +assert result is not None # Smoke test only; SDK guarantees non-None on 2xx +``` + +## Multipart Bodies (Hard-Fail) + +The Python SDK emitter does **not** support multipart request bodies. Attempting to emit a scenario with a multipart step will throw: + +``` +[PythonSdkEmitter] Hard-fail: multipart body in step 0 (createDeployment). +The Python SDK does not support multipart uploads. +This scenario cannot be emitted. +``` + +This is a known limitation and will surface at generation time rather than producing a broken test. + +## Seed Bindings + +Scenarios may declare `seedBindings` (variables with `__PENDING__` values). These are seeded at test startup: + +```python +# Literal bindings +ctx['tenantId'] = '' + +# Runtime-generated bindings +if 'processDefinitionKey' not in ctx: + ctx['processDefinitionKey'] = seedBinding('processDefinitionKey') +``` + +## Assertion Strategy + +Per the specification, the Python SDK emitter relies on: + +1. **SDK throws on non-2xx** — no explicit status assertion needed +2. **Plain `assert` statements** — no external validation library (jsonschema, pydantic, deepdiff) +3. **extract_into() for response binding** — attributes are strongly typed, not raw JSON + +Example: + +```python +result = await client.activate_jobs(data=...) +assert result is not None +extract_into(ctx, 'jobKey', result.jobs[0].key) +``` + +## Known Limitations + +- **No request-validation parity**: The Python emitter is path-analyser only. HTTP-level request-validation (negative tests, HTTP 400 expectations) is out of scope and remains in `request-validation/`. +- **No multipart support**: Scenarios with `bodyKind === 'multipart'` will hard-fail. +- **Simplified type inference**: Model class names are inferred from `operationId` via a heuristic (PascalCase + "Request" suffix). For accuracy, the SDK's type stubs should be consulted. + +## Regress Testing + +Layer-3 regression invariants in `configs/camunda-oca/regression-invariants.test.ts` assert: + +1. Every URL placeholder is either seeded or extracted by an upstream step (mirrors Bug A from JS SDK) +2. The emitter's operation-map keyset matches the Python SDK's `examples/operation-map.json` under CI + +Run the full pipeline + tests locally before pushing: + +```bash +npm run pipeline +npm test +``` diff --git a/materializer/src/python-sdk/emitter.ts b/materializer/src/python-sdk/emitter.ts new file mode 100644 index 00000000..9cb1e4dd --- /dev/null +++ b/materializer/src/python-sdk/emitter.ts @@ -0,0 +1,360 @@ +/** + * Python SDK Emitter — generates async pytest test suites for Camunda REST API. + * + * Lowers an `EndpointScenarioCollection` to Python test file(s) that use + * the CamundaAsyncClient from the camunda-orchestration-sdk. + * + * Design: + * - Pure: no filesystem access (orchestrator handles materialization) + * - One async def test_(client) per scenario + * - SDK raises on non-2xx; plain assert result is not None + * - extract_into(ctx, 'bind', value) for response field extraction + * - Hard-fail on multipart (unsupported in Python SDK integration) + */ + +import type { EmitContext, EmittedFile, EmitterStrategy } from '@camunda8/emitter-sdk'; +import type { EndpointScenario, EndpointScenarioCollection } from 'path-analyser/types'; +import { + camelToSnake, + createDefaultOperationMapSource, + type OperationMapJsonSource, +} from './sdk-mapping.js'; + +/** + * Render a value as a Python literal. + * + * Handles: + * - null → None + * - boolean → True/False + * - string → properly escaped, with template ${var} → ctx['var'] conversion + * - number → as-is + * - array → [...] + * - object → {...} + * + * Example: + * toPythonLiteral({ type: '${workerType}', active: true }) + * → {"type": ctx['workerType'], "active": True} + */ +function toPythonLiteral(value: unknown): string { + // Handle null + if (value === null) return 'None'; + + // Handle booleans + if (typeof value === 'boolean') return value ? 'True' : 'False'; + + // Handle strings + if (typeof value === 'string') { + // Check if this is a template placeholder (e.g., "${varName}") + if (value.includes('${')) { + // Replace ${varName} with ctx['varName'] (no quotes) + return value.replace(/\$\{([^}]+)\}/g, "ctx['$1']"); + } + + // Regular string: choose quotes intelligently + if (value.includes("'") && !value.includes('"')) { + // Has single quotes but not double → use double quotes + return `"${value}"`; + } + + // Otherwise use single quotes with escaping + const escaped = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + return `'${escaped}'`; + } + + // Handle numbers + if (typeof value === 'number') return String(value); + + // Handle arrays + if (Array.isArray(value)) { + const items = value.map((v) => toPythonLiteral(v)).join(', '); + return `[${items}]`; + } + + // Handle objects + if (typeof value === 'object' && value !== null) { + const entries = Object.entries(value) + .map(([k, v]) => `'${k}': ${toPythonLiteral(v)}`) + .join(', '); + return `{${entries}}`; + } + + // Fallback + return 'None'; +} + +/** + * File name for Python SDK generated test suite. + * + * Pattern: .python_sdk.spec.py + * Uses the operationId directly (camelCase) to match the JS SDK convention. + */ +function pythonSdkFileName(operationId: string): string { + return `${operationId}.python_sdk.spec.py`; +} + +/** + * Render a scenario as a Python async test function. + * + * Structure: + * - async def test_(client: CamundaAsyncClient) -> None + * - ctx initialization and seed binding + * - Request plan steps with await client.() calls + * - extract_into() for response fields + * - Plain assert result is not None + */ +function renderScenarioTest( + scenario: EndpointScenario, + operationMapSource: OperationMapJsonSource, + modelImports: Set, +): string { + const lines: string[] = []; + + // Function signature: convert scenario id to valid Python identifier + // Replace hyphens (common in scenario ids like sc-activate-jobs-simple) with underscores + const testName = camelToSnake((scenario.id || 'test').replace(/-/g, '_')); + const testFuncName = `test_${testName}`; + lines.push('@pytest.mark.asyncio'); + lines.push(`async def ${testFuncName}(client: CamundaAsyncClient) -> None:`); + if (scenario.description) { + lines.push(` """${scenario.description}"""`); + } else if (scenario.name) { + lines.push(` """${scenario.name}"""`); + } + lines.push(''); + + // Context dict initialization + lines.push(' ctx: dict[str, Any] = {}'); + lines.push(''); + + // Seed bindings (from scenario.bindings) + const bindings = scenario.bindings || {}; + + // Emit literal bindings first + if (Object.keys(bindings).length > 0) { + lines.push(' # Seed scenario bindings'); + for (const [k, v] of Object.entries(bindings)) { + if (v === '__PENDING__') continue; // Skip pending markers + // Render value as Python literal (handles booleans, nulls, templates, etc.) + const pyValue = toPythonLiteral(v); + lines.push(` ctx['${k}'] = ${pyValue}`); + } + lines.push(''); + } + + // Emit seedBinding() calls for PENDING bindings + const seedBindings = Object.entries(bindings) + .filter(([, v]) => v === '__PENDING__') + .map(([k]) => k); + if (seedBindings.length > 0) { + lines.push(' # Seed runtime-generated bindings'); + for (const k of seedBindings) { + lines.push(` if '${k}' not in ctx:`); + lines.push(` ctx['${k}'] = seedBinding('${k}')`); + } + lines.push(''); + } + + // Request plan + if (!scenario.requestPlan || scenario.requestPlan.length === 0) { + lines.push(' # No request plan'); + lines.push(' pass'); + return lines.join('\n'); + } + + const requestPlan = scenario.requestPlan; + for (let stepIdx = 0; stepIdx < requestPlan.length; stepIdx++) { + const step = requestPlan[stepIdx]; + const isFinal = stepIdx === requestPlan.length - 1; + + // Check for unsupported multipart + if (step.bodyKind === 'multipart') { + throw new Error( + `[PythonSdkEmitter] Hard-fail: multipart body in step ${stepIdx} (${step.operationId}). ` + + `The Python SDK does not support multipart uploads. ` + + `This scenario cannot be emitted.`, + ); + } + + lines.push(` # Step ${stepIdx + 1}: ${step.operationId}`); + + // Build request body (if present) + if (step.bodyTemplate && step.bodyKind === 'json') { + const bodyDict = buildBodyDict(step.bodyTemplate); + lines.push(` request_body = ${bodyDict}`); + } + + // Determine Python method name + const pythonMethod = operationMapSource.resolvePythonMethod(step.operationId); + + // Build kwargs for the client method call + const kwargs: string[] = []; + if (step.bodyTemplate && step.bodyKind === 'json') { + // Use from_dict() with model class name derived from operationId + const modelClassName = inferModelClassName(step.operationId); + modelImports.add(modelClassName); + kwargs.push(`data=${modelClassName}.from_dict(request_body)`); + } + + // Add query/path parameters (simplified for now) + if (step.pathParams && step.pathParams.length > 0) { + for (const param of step.pathParams) { + kwargs.push(`${param.name}=ctx.get('${param.var}')`); + } + } + + // Build the await call + const awaitCall = `await client.${pythonMethod}(${kwargs.join(', ')})`; + lines.push(` result = ${awaitCall}`); + + // Assert result is not None (SDK raises on non-2xx) + lines.push(` assert result is not None, '${step.operationId} must return a response'`); + + // Extract response fields + if (step.extract && step.extract.length > 0) { + lines.push(''); + for (const ex of step.extract) { + const accessor = fieldPathToAccessor(ex.fieldPath); + lines.push(` extract_into(ctx, '${ex.bind}', result${accessor})`); + } + } + + if (!isFinal) { + lines.push(''); + } + } + + return lines.join('\n'); +} + +/** + * Build a Python dict representation from a request template. + * + * Renders the template as a Python literal, handling: + * - ${var} placeholders → ctx['var'] lookups + * - boolean true/false → True/False + * - null → None + * - proper string escaping + * + * Example: + * { type: "${workerType}", active: true, metadata: null } + * → + * {'type': ctx['workerType'], 'active': True, 'metadata': None} + */ +function buildBodyDict(bodyTemplate: unknown): string { + return toPythonLiteral(bodyTemplate); +} + +/** + * Infer a Python model class name from an operationId. + * + * Example: activateJobs → ActivateJobsRequest + * + * This is a heuristic; the correct model name should be loaded from + * the SDK's type stubs or operation-map. For now, we use a simple + * PascalCase + "Request" suffix pattern. + */ +function inferModelClassName(operationId: string): string { + // Convert camelCase to PascalCase + const pascal = operationId.charAt(0).toUpperCase() + operationId.slice(1); + return `${pascal}Request`; +} + +/** + * Convert a field path (e.g., "jobs[0].key") to a Python accessor. + * + * Example: + * jobs[0].key → ['jobs'][0]['key'] + * metadata.processInstanceKey → ['metadata']['processInstanceKey'] + */ +function fieldPathToAccessor(fieldPath: string): string { + // Parse field path into segments: field, [index], .field, etc. + const segments = fieldPath.split(/\.|\[|\]/); + let accessor = ''; + + for (const seg of segments) { + if (seg === '') continue; // Skip empty parts from split + if (/^\d+$/.test(seg)) { + // Numeric index: [0] + accessor += `[${seg}]`; + } else { + // Field name: .fieldName or ['fieldName'] + accessor += `['${seg}']`; + } + } + + return accessor; +} + +/** + * Render the full Python test suite as a string. + */ +function renderPythonTestSuite( + collection: EndpointScenarioCollection, + operationMapSource: OperationMapJsonSource, +): string { + const lines: string[] = []; + const modelImports = new Set(); + const scenarioBlocks: string[] = []; + + // Scenarios as test functions (collect model imports while rendering) + for (const scenario of collection.scenarios) { + scenarioBlocks.push(renderScenarioTest(scenario, operationMapSource, modelImports)); + } + + // Header + lines.push(`# Test suite for ${collection.endpoint.operationId}`); + lines.push('# This file is auto-generated. Do not edit.'); + lines.push(''); + + // Imports + lines.push('from typing import Any'); + lines.push('import pytest'); + lines.push('from camunda.client import CamundaAsyncClient'); + if (modelImports.size > 0) { + const sortedImports = Array.from(modelImports).sort(); + lines.push(`from camunda.models import ${sortedImports.join(', ')}`); + } + lines.push('from helper import extract_into, seedBinding'); + lines.push(''); + + for (const block of scenarioBlocks) { + lines.push(block); + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Factory: create a Python SDK emitter backed by the given operation map. + */ +export function createPythonSdkEmitter( + operationMapSource?: OperationMapJsonSource, +): EmitterStrategy { + const source = operationMapSource ?? createDefaultOperationMapSource(); + return { + id: 'python-sdk', + name: 'Python SDK (Async)', + supportedConfigs: ['*'], + async emit(collection: EndpointScenarioCollection, _ctx: EmitContext): Promise { + const content = renderPythonTestSuite(collection, source); + return [ + { + relativePath: pythonSdkFileName(collection.endpoint.operationId), + content, + }, + ]; + }, + }; +} + +/** + * {@link EmitterStrategy} implementation for Python SDK tests. + * + * Pure: returns in-memory {@link EmittedFile} list, no filesystem access. + * Uses default operation map (fallback camelToSnake). + * + * For production use, consider using createPythonSdkEmitter() with a loaded + * operation-map.json source for more accurate method name resolution. + */ +export const PythonSdkEmitter: EmitterStrategy = createPythonSdkEmitter(); diff --git a/materializer/src/python-sdk/materialize-support.ts b/materializer/src/python-sdk/materialize-support.ts new file mode 100644 index 00000000..af5b8b73 --- /dev/null +++ b/materializer/src/python-sdk/materialize-support.ts @@ -0,0 +1,155 @@ +/** + * Materialize Python SDK test support files into the generated output directory. + * + * Vendors self-contained Python support modules so generated test suites + * are runnable standalone without any dependency on this generator project. + * + * Files materialized: + * - conftest.py — pytest session fixture with CamundaAsyncClient + * - helper.py — extract_into() and seedBinding() helpers + * - requirements.txt — dependencies (camunda-orchestration-sdk, pytest, pytest-asyncio) + * - pytest.ini — pytest configuration (asyncio_mode = auto) + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +const CONFTEST_PY = `""" +Pytest configuration for Camunda API test suite. + +Session-scoped client fixture and asyncio configuration. +""" + +import os +import pytest +from camunda.client import CamundaAsyncClient + + +@pytest.fixture(scope='session') +def client() -> CamundaAsyncClient: + """ + Session-scoped CamundaAsyncClient fixture. + + Supports both local (unauthenticated) and SaaS (OAuth2) configurations + via environment variables: + + Local (unauthenticated): + CAMUNDA_BASE_URL=http://localhost:8080 + (no auth env vars needed) + + SaaS (OAuth2): + CAMUNDA_BASE_URL=https://.camunda.cloud + CAMUNDA_CLIENT_ID= + CAMUNDA_CLIENT_SECRET= + CAMUNDA_OAUTH_URL=https://.auth.camunda.cloud + """ + base_url = os.getenv('CAMUNDA_BASE_URL', 'http://localhost:8080') + client_id = os.getenv('CAMUNDA_CLIENT_ID') + client_secret = os.getenv('CAMUNDA_CLIENT_SECRET') + oauth_url = os.getenv('CAMUNDA_OAUTH_URL') + + # Create client with optional OAuth2 credentials + if client_id and client_secret and oauth_url: + return CamundaAsyncClient( + base_url=base_url, + client_id=client_id, + client_secret=client_secret, + oauth_url=oauth_url, + ) + else: + # Local unauthenticated mode + return CamundaAsyncClient(base_url=base_url) +`; + +const HELPER_PY = `""" +Test helper functions for Camunda API test suite. + +Provides: + - extract_into() — extract response fields into context dict + - seedBinding() — seed random or default values for test variables +""" + +import random +import string +import uuid +from typing import Any, Optional + + +def extract_into(ctx: dict[str, Any], bind_name: str, value: Any) -> None: + """ + Extract a value from a response and store it in the test context. + + Preserves existing bindings (skips assignment if value is None or + undefined), so seeded bindings from earlier steps are not overwritten + by responses that omit the field. + + Args: + ctx: Test context dict (mutated in-place) + bind_name: Key to store the value under + value: Value to extract (assignment skipped if None) + """ + if value is not None: + ctx[bind_name] = value + + +def seedBinding( + bind_name: str, + default_value: Optional[str | int | float | bool] = None, +) -> str | int | float | bool: + """ + Seed a random or default value for a test variable. + + Called during scenario setup to populate undefined bindings. + Generates UUIDs for identifier types (default), or returns the + provided default_value if supplied. + + Args: + bind_name: Name of the binding (used for logging/debugging) + default_value: Optional literal value to return instead of generating random + + Returns: + The default_value if supplied, otherwise a generated UUID string + """ + if default_value is not None: + return default_value + # Generate a UUID for identifier-type bindings + return str(uuid.uuid4()) +`; + +const REQUIREMENTS_TXT = `camunda-orchestration-sdk>=1.0.0 +pytest>=7.0 +pytest-asyncio>=0.21.0 +`; + +const PYTEST_INI = `[pytest] +asyncio_mode = auto +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* +`; + +/** + * Materialize Python support files into the generated test suite directory. + * + * Creates: + * - /conftest.py — pytest configuration + client fixture + * - /helper.py — test helper functions + * - /requirements.txt — Python dependencies + * - /pytest.ini — pytest config (asyncio_mode = auto) + */ +export async function materializePythonSupport(outDir: string): Promise { + const files: Array<[string, string]> = [ + ['conftest.py', CONFTEST_PY], + ['helper.py', HELPER_PY], + ['requirements.txt', REQUIREMENTS_TXT], + ['pytest.ini', PYTEST_INI], + ]; + + await fs.mkdir(outDir, { recursive: true }); + + for (const [filename, content] of files) { + const filePath = path.join(outDir, filename); + await fs.writeFile(filePath, content, 'utf8'); + } +} diff --git a/materializer/src/python-sdk/sdk-mapping.ts b/materializer/src/python-sdk/sdk-mapping.ts new file mode 100644 index 00000000..37a78009 --- /dev/null +++ b/materializer/src/python-sdk/sdk-mapping.ts @@ -0,0 +1,105 @@ +/** + * Python SDK operation mapping — converts camelCase operationId to snake_case + * method names and loads the operation-map.json from the Python SDK. + * + * The Python SDK's operation-map.json keys are already in snake_case + * (e.g., "get_agent_instance", "create_deployment"). This module provides + * helpers to: + * + * 1. Load the map from spec/python-sdk/operation-map.json + * 2. Convert operationId (camelCase) to snake_case for map lookup + * 3. Resolve the mapped Python method name or fall back to camelToSnake() + */ + +/** + * Convert camelCase to snake_case. + * + * Examples: + * activateJobs → activate_jobs + * createDeployment → create_deployment + * deleteProcessDefinition → delete_process_definition + */ +export function camelToSnake(str: string): string { + return str + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, ''); +} + +/** + * Convert PascalCase (e.g. "DeployResources") to snake_case. + * Used for region values in the operation-map. + * + * Examples: + * DeployResources → deploy_resources + * ActivateJobs → activate_jobs + */ +export function pascalToSnake(str: string): string { + return str + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, ''); +} + +/** + * Operation-map entry structure (from examples/operation-map.json) + * + * The map's values are arrays where the first element contains the region + * (Python method symbol). Example: + * + * { + * "activate_jobs": [{ "region": "activate_jobs" }], + * "create_deployment": [{ "region": "create_deployment" }] + * } + */ +interface OperationMapEntry { + region?: string; + [key: string]: unknown; +} + +/** + * Python SDK operation mapping source. + * + * Loads the operation-map.json from spec/python-sdk/ (populated by + * fetch-python-sdk-map.ts) and provides lookup methods. + */ +export interface OperationMapJsonSource { + /** Resolve operationId (camelCase) → Python method symbol (snake_case). */ + resolvePythonMethod(operationId: string): string; +} + +/** + * Create an OperationMapJsonSource from parsed operation-map.json data. + * + * The operationId is converted from camelCase to snake_case, then looked up + * in the map. If found, the first entry's "region" field is used as the + * Python method symbol. Otherwise, falls back to camelToSnake(operationId). + */ +export function createOperationMapSource( + mapData: Record, +): OperationMapJsonSource { + return { + resolvePythonMethod(operationId: string): string { + const snakeOperationId = camelToSnake(operationId); + const entry = mapData[snakeOperationId]; + if (entry && entry.length > 0 && entry[0].region) { + // Region values are already in snake_case method form + return entry[0].region; + } + // Fallback: convert operationId directly + return snakeOperationId; + }, + }; +} + +/** + * Default operation map source — used when the Python SDK map is unavailable. + * Simple fallback: convert operationId camelCase → snake_case. + */ +export function createDefaultOperationMapSource(): OperationMapJsonSource { + return { + resolvePythonMethod(operationId: string): string { + return camelToSnake(operationId); + }, + }; +} diff --git a/materializer/templates/tsconfig.json b/materializer/templates/tsconfig.json index ecafe08c..5edb44c4 100644 --- a/materializer/templates/tsconfig.json +++ b/materializer/templates/tsconfig.json @@ -10,6 +10,6 @@ "forceConsistentCasingInFileNames": true, "types": ["node"] }, - "include": ["**/*.ts"], + "include": ["**/*.spec.ts"], "exclude": ["node_modules"] } diff --git a/package-lock.json b/package-lock.json index 0c9594f3..491a0367 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2921,6 +2921,7 @@ "path-analyser": { "version": "0.1.0", "dependencies": { + "@camunda8/emitter-sdk": "*", "@playwright/test": "^1.54.2", "ajv": "^8.20.0", "yaml": "^2.5.0", diff --git a/package.json b/package.json index d0ea3cae..6c07a66f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "scripts": { "fetch-spec": "tsx scripts/fetch-spec.ts", "fetch-spec:ref": "tsx scripts/fetch-spec.ts --ref-required", + "fetch-python-sdk-map": "tsx scripts/fetch-python-sdk-map.ts", "with-config": "tsx scripts/with-config.ts", "build:ontology": "tsx scripts/build-ontology.ts", "export:ontology": "tsx scripts/export-ontology.ts", @@ -28,18 +29,23 @@ "generate:request-validation:shallow": "npm run generate:shallow -w request-validation && npm run biome:fix-generated:request-validation", "codegen:playwright": "npm run build:emitter-sdk && tsx materializer/src/index.ts && npm run biome:fix-generated:codegen", "codegen:playwright:all": "npm run build:emitter-sdk && tsx materializer/src/index.ts --all && npm run biome:fix-generated:codegen", - "test:pw": "npm run test:pw:path-analyser && npm run test:pw:request-validation", + "codegen:js-sdk:all": "tsx materializer/src/index.ts --target=js-sdk --all", + "codegen:python-sdk:all": "tsx materializer/src/index.ts --target=python-sdk --all", + "codegen:python-sdk": "tsx materializer/src/index.ts --target=python-sdk", + "codegen:csharp-sdk:all": "tsx materializer/src/index.ts --target=csharp-sdk --all", "test:pw:path-analyser": "npx playwright test -c path-analyser/playwright.config.ts", "test:pw:request-validation": "tsx scripts/run-pw-request-validation.ts", - "testsuite:generate": "npm run extract-graph && npm run generate:scenarios && npm run codegen:playwright:all", + "testsuite:generate": "npm run extract-graph && npm run generate:scenarios && npm run codegen:playwright:all && npm run codegen:js-sdk:all && npm run codegen:python-sdk:all && npm run codegen:csharp-sdk:all", "testsuite:observe:run": "npm run codegen:playwright:all && npm run test:pw:path-analyser && npm run observe:aggregate", "observe:aggregate": "npm run build:analyser && node path-analyser/dist/src/scripts/aggregate-observations.js", "optional-responses": "node optional-responses/report.js", - "pipeline": "npm run fetch-spec && npm run testsuite:generate && npm run generate:request-validation", + "pipeline": "npm run fetch-spec && npm run fetch-python-sdk-map && npm run testsuite:generate && npm run generate:request-validation", + "fetch-js-sdk-map": "node scripts/fetch-js-sdk-map.js", + "test:pw": "npm run test:pw:path-analyser && npm run test:pw:request-validation", "lint": "biome check", "lint:fix": "biome check --write", - "lint:generated": "biome check --config-path=biome.generated.json generated", - "biome:fix-generated": "biome check --config-path=biome.generated.json --write --unsafe generated", + "lint:generated": "biome check --config-path=biome.generated.json", + "biome:fix-generated": "biome check --config-path=biome.generated.json --write --unsafe", "biome:fix-generated:codegen": "npm run biome:fix-generated", "biome:fix-generated:request-validation": "npm run biome:fix-generated", "format": "biome format --write", diff --git a/scripts/fetch-js-sdk-map.js b/scripts/fetch-js-sdk-map.js new file mode 100644 index 00000000..0feff54e --- /dev/null +++ b/scripts/fetch-js-sdk-map.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +/** + * Fetches `examples/operation-map.json` from the + * `camunda/orchestration-cluster-api-js` repository via a git sparse clone. + * + * Usage: + * npm run fetch-js-sdk-map + * JS_SDK_REF=main npm run fetch-js-sdk-map + * + * Output: spec/js-sdk/operation-map.json (gitignored alongside spec/bundled/). + * + * Analogous to the OpenAPI spec fetch via `camunda-schema-bundler`: + * - `JS_SDK_REF` controls which branch / tag / SHA is fetched (default: main). + * - The file is never committed; CI fetches it fresh each run. + * - A future pin file (analogous to spec-pin.json) can lock the SHA for + * determinism across runs. + */ + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_URL = 'https://github.com/camunda/orchestration-cluster-api-js.git'; +const FILE_PATH = 'examples/operation-map.json'; +const SDK_REF = process.env.JS_SDK_REF || 'main'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = path.join(repoRoot, 'spec', 'js-sdk'); +const outFile = path.join(outDir, 'operation-map.json'); +const tmpDir = path.join(tmpdir(), `js-sdk-map-${Date.now()}`); + +console.log(`[fetch-js-sdk-map] Fetching ${FILE_PATH} from ${REPO_URL} @ ${SDK_REF}`); + +try { + mkdirSync(tmpDir, { recursive: true }); + + // Sparse clone: fetch only the single file to keep it fast. + execFileSync('git', ['init', '--quiet', tmpDir], { stdio: 'inherit' }); + execFileSync('git', ['-C', tmpDir, 'remote', 'add', 'origin', REPO_URL], { + stdio: 'inherit', + }); + execFileSync( + 'git', + ['-C', tmpDir, 'config', 'core.sparseCheckout', 'true'], + { stdio: 'inherit' }, + ); + // Write sparse-checkout pattern before fetch + const sparseFile = path.join(tmpDir, '.git', 'info', 'sparse-checkout'); + writeFileSync(sparseFile, `${FILE_PATH}\n`, 'utf8'); + execFileSync( + 'git', + ['-C', tmpDir, 'fetch', '--depth', '1', 'origin', SDK_REF], + { stdio: 'inherit' }, + ); + execFileSync( + 'git', + ['-C', tmpDir, 'checkout', 'FETCH_HEAD', '--', FILE_PATH], + { stdio: 'inherit' }, + ); + + mkdirSync(outDir, { recursive: true }); + renameSync(path.join(tmpDir, FILE_PATH), outFile); + + console.log(`[fetch-js-sdk-map] Written to ${path.relative(repoRoot, outFile)}`); +} catch (err) { + console.error('[fetch-js-sdk-map] Failed:', err instanceof Error ? err.message : String(err)); + process.exit(1); +} finally { + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Non-fatal cleanup failure. + } +} diff --git a/scripts/fetch-python-sdk-map.ts b/scripts/fetch-python-sdk-map.ts new file mode 100644 index 00000000..d857c55a --- /dev/null +++ b/scripts/fetch-python-sdk-map.ts @@ -0,0 +1,101 @@ +#!/usr/bin/env tsx +/** + * fetch-python-sdk-map — sparse-clone camunda/orchestration-cluster-api-python + * and extract examples/operation-map.json. + * + * Mirrors scripts/fetch-js-sdk-map.js pattern. Outputs to spec/python-sdk/ + * per the CONFIG-partitioned layout. + * + * Outputs: + * spec/python-sdk/operation-map.json (the SDK mapping, never committed) + * spec/python-sdk/sdk-metadata.json (resolved ref SHA, content hash) + * + * Env vars: + * PYTHON_SDK_REF Commit/branch/tag to fetch (default: main) + */ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const REPO_URL = 'https://github.com/camunda/orchestration-cluster-api-python.git'; +const FILE_PATH = 'examples/operation-map.json'; + +const __filename = fileURLToPath(import.meta.url); +const REPO_ROOT = resolve(__filename, '../..'); + +interface SdkMetadata { + /** Resolved 40-char commit SHA from camunda/orchestration-cluster-api-python */ + sdkRef: string; + /** SHA-256 hash of operation-map.json content */ + operationMapHash: string; + /** Timestamp of fetch */ + fetchedAt: string; +} + +function computeHash(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} + +const pythonSdkRef = process.env.PYTHON_SDK_REF ?? 'main'; +const pythonSdkDir = join(REPO_ROOT, 'spec/python-sdk'); +const operationMapPath = join(pythonSdkDir, 'operation-map.json'); +const metadataPath = join(pythonSdkDir, 'sdk-metadata.json'); +const tmpDir = join(tmpdir(), `python-sdk-map-${Date.now()}`); + +console.log(`[fetch-python-sdk-map] Fetching ${FILE_PATH} from ${REPO_URL} @ ${pythonSdkRef}`); + +try { + mkdirSync(tmpDir, { recursive: true }); + + // Sparse clone: fetch only examples/operation-map.json to keep it fast. + execFileSync('git', ['init', '--quiet', tmpDir], { stdio: 'inherit' }); + execFileSync('git', ['-C', tmpDir, 'remote', 'add', 'origin', REPO_URL], { stdio: 'inherit' }); + execFileSync('git', ['-C', tmpDir, 'config', 'core.sparseCheckout', 'true'], { + stdio: 'inherit', + }); + + // Write sparse-checkout pattern before fetch + const sparseFile = join(tmpDir, '.git', 'info', 'sparse-checkout'); + writeFileSync(sparseFile, `${FILE_PATH}\n`, 'utf8'); + + execFileSync('git', ['-C', tmpDir, 'fetch', '--depth', '1', 'origin', pythonSdkRef], { + stdio: 'inherit', + }); + execFileSync('git', ['-C', tmpDir, 'checkout', 'FETCH_HEAD', '--', FILE_PATH], { + stdio: 'inherit', + }); + + // Resolve the ref to a full commit SHA + const resolvedRef = execFileSync('git', ['-C', tmpDir, 'rev-parse', 'FETCH_HEAD'], { + encoding: 'utf-8', + }).trim(); + console.log(`[fetch-python-sdk-map] resolved ref: ${resolvedRef}`); + + // Ensure output directory exists and move the file into place + mkdirSync(pythonSdkDir, { recursive: true }); + renameSync(join(tmpDir, FILE_PATH), operationMapPath); + console.log(`[fetch-python-sdk-map] Written to ${operationMapPath}`); + + // Write metadata + const operationMapContent = readFileSync(operationMapPath, 'utf-8'); + const metadata: SdkMetadata = { + sdkRef: resolvedRef, + operationMapHash: computeHash(operationMapContent), + fetchedAt: new Date().toISOString(), + }; + writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); + console.log(`[fetch-python-sdk-map] Written metadata to ${metadataPath}`); +} catch (err) { + console.error('[fetch-python-sdk-map] Failed:', err instanceof Error ? err.message : String(err)); + process.exit(1); +} finally { + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Non-fatal cleanup failure. + } +} diff --git a/tests/codegen/csharp-sdk-emitter.test.ts b/tests/codegen/csharp-sdk-emitter.test.ts new file mode 100644 index 00000000..2bf0fee4 --- /dev/null +++ b/tests/codegen/csharp-sdk-emitter.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from 'vitest'; +import { createCsharpEmitter } from '../../materializer/src/csharp-sdk/emitter.ts'; +import type { EndpointScenarioCollection } from '../../path-analyser/src/types.ts'; + +const COLLECTION: EndpointScenarioCollection = { + endpoint: { operationId: 'createWidget', method: 'POST', path: '/widgets' }, + requiredSemanticTypes: [], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc1', + name: 'happy path', + operations: [{ operationId: 'createWidget', method: 'POST', path: '/widgets' }], + producedSemanticTypes: [], + satisfiedSemanticTypes: [], + }, + ], +}; + +const DEPLOYMENT_COLLECTION: EndpointScenarioCollection = { + endpoint: { operationId: 'createDeployment', method: 'POST', path: '/deployments' }, + requiredSemanticTypes: [], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc1', + name: 'happy path', + operations: [{ operationId: 'createDeployment', method: 'POST', path: '/deployments' }], + producedSemanticTypes: [], + satisfiedSemanticTypes: [], + requestPlan: [ + { + operationId: 'createDeployment', + method: 'POST', + pathTemplate: '/deployments', + bodyKind: 'multipart', + multipartTemplate: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional binding placeholder for C# runtime template resolution + fields: { tenantId: '${tenantIdVar}' }, + files: { resource: '@@FILE:fixtures/bpmn/sample.bpmn' }, + }, + expect: { status: 200 }, + }, + ], + }, + ], +}; + +const PROCESS_INSTANCE_COLLECTION: EndpointScenarioCollection = { + endpoint: { operationId: 'createProcessInstance', method: 'POST', path: '/process-instances' }, + requiredSemanticTypes: [], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc1', + name: 'happy path', + operations: [ + { operationId: 'createDeployment', method: 'POST', path: '/deployments' }, + { operationId: 'createProcessInstance', method: 'POST', path: '/process-instances' }, + ], + producedSemanticTypes: [], + satisfiedSemanticTypes: [], + requestPlan: [ + { + operationId: 'createProcessInstance', + method: 'POST', + pathTemplate: '/process-instances', + bodyKind: 'json', + bodyTemplate: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional binding placeholder for C# runtime template resolution + processDefinitionKey: '${processDefinitionKeyVar}', + variables: { foo: 'bar' }, + }, + expect: { status: 200 }, + extract: [{ fieldPath: 'processInstanceKey', bind: 'processInstanceKeyVar' }], + }, + ], + }, + ], +}; + +const SEARCH_JOBS_COLLECTION: EndpointScenarioCollection = { + endpoint: { operationId: 'searchJobs', method: 'POST', path: '/jobs/search' }, + requiredSemanticTypes: [], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc1', + name: 'happy path', + operations: [{ operationId: 'searchJobs', method: 'POST', path: '/jobs/search' }], + producedSemanticTypes: [], + satisfiedSemanticTypes: [], + requestPlan: [ + { + operationId: 'searchJobs', + method: 'POST', + pathTemplate: '/jobs/search', + bodyKind: 'json', + bodyTemplate: { + page: { limit: 5 }, + filter: { type: 'payment' }, + }, + expect: { status: 200 }, + }, + ], + }, + ], +}; + +describe('C# SDK emitter (Emitter contract)', () => { + test('id and name are stable identifiers', () => { + const emitter = createCsharpEmitter({}); + expect(emitter.id).toBe('csharp-sdk'); + expect(emitter.name).toMatch(/C# SDK/); + }); + + test('returns one EmittedFile with a csharp path', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(COLLECTION, { + outDir: '/unused', + suiteName: 'createWidget', + mode: 'feature', + }); + expect(files).toHaveLength(1); + expect(files[0].relativePath).toBe('csharp/createWidget.feature.cs'); + }); + + test('emit() is pure: does not touch the filesystem (outDir is unused)', async () => { + const emitter = createCsharpEmitter({}); + await expect( + emitter.emit(COLLECTION, { + outDir: '/this/does/not/exist', + suiteName: 'createWidget', + mode: 'feature', + }), + ).resolves.toBeDefined(); + }); + + test('operation-map entries override the default method name', async () => { + const emitter = createCsharpEmitter({ + createDeployment: [{ region: 'CreateDeploymentCustomAsync' }], + }); + const files = await emitter.emit(DEPLOYMENT_COLLECTION, { + outDir: '/unused', + suiteName: 'createDeployment', + mode: 'feature', + }); + expect(files[0].content).toContain('client.CreateDeploymentCustomAsync'); + }); + + test('emits core SDK call scaffolding for createProcessInstance', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(PROCESS_INSTANCE_COLLECTION, { + outDir: '/unused', + suiteName: 'createProcessInstance', + mode: 'feature', + }); + const content = files[0].content; + expect(content).toContain('var instanceRequest = FromTemplate'); + expect(content).toContain('client.CreateProcessInstanceAsync'); + expect(content).toContain( + "ApplyExtract(ctx, createProcessInstanceResponse, 'processInstanceKey', 'processInstanceKeyVar');", + ); + }); + + test('emits SDK call scaffolding for searchJobs', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(SEARCH_JOBS_COLLECTION, { + outDir: '/unused', + suiteName: 'searchJobs', + mode: 'feature', + }); + const content = files[0].content; + expect(content).toContain('var searchRequest = FromTemplate'); + expect(content).toContain('client.SearchJobsAsync'); + }); +}); diff --git a/tests/codegen/js-sdk-emitter.test.ts b/tests/codegen/js-sdk-emitter.test.ts new file mode 100644 index 00000000..7e816d84 --- /dev/null +++ b/tests/codegen/js-sdk-emitter.test.ts @@ -0,0 +1,439 @@ +import { describe, expect, it } from 'vitest'; +import { + createJsSdkEmitter, + jsSdkSuiteFileName, + renderJsSdkSuite, +} from '../../materializer/src/js-sdk/emitter.ts'; +import { + FallbackMappingSource, + OperationMapJsonSource, +} from '../../materializer/src/js-sdk/sdk-mapping.ts'; +import type { EndpointScenarioCollection } from '../../path-analyser/src/types.ts'; + +/** + * Layer-1 fixture — JS SDK emitter. + * + * Each `it` block asserts one property of the lowering from a hand-built + * `EndpointScenarioCollection` to emitted Vitest source. The fixtures here + * are the regression guard: if the emitter changes the generated output in a + * breaking way, exactly the affected assertion fails. + */ + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const FALLBACK_MAPPING = new FallbackMappingSource(); + +const MINIMAL_COLLECTION: EndpointScenarioCollection = { + endpoint: { operationId: 'getTopology', method: 'GET', path: '/topology' }, + requiredSemanticTypes: [], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc1', + name: 'get topology', + operations: [{ operationId: 'getTopology', method: 'GET', path: '/topology' }], + producedSemanticTypes: [], + satisfiedSemanticTypes: [], + }, + ], +}; + +const JSON_BODY_COLLECTION: EndpointScenarioCollection = { + endpoint: { + operationId: 'createProcessInstance', + method: 'POST', + path: '/process-instances', + }, + requiredSemanticTypes: ['ProcessDefinitionKey'], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc1', + name: 'create by definition key', + operations: [ + { + operationId: 'createProcessDefinition', + method: 'POST', + path: '/process-definitions', + }, + { + operationId: 'createProcessInstance', + method: 'POST', + path: '/process-instances', + }, + ], + producedSemanticTypes: ['ProcessInstanceKey'], + satisfiedSemanticTypes: ['ProcessDefinitionKey'], + requestPlan: [ + { + operationId: 'createProcessDefinition', + method: 'POST', + pathTemplate: '/process-definitions', + bodyKind: 'json', + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — generator template placeholder + bodyTemplate: { name: '${processDefNameVar}' }, + expect: { status: 200 }, + extract: [{ fieldPath: 'key', bind: 'processDefinitionKeyVar' }], + }, + { + operationId: 'createProcessInstance', + method: 'POST', + pathTemplate: '/process-instances', + bodyKind: 'json', + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — generator template placeholder + bodyTemplate: { processDefinitionKey: '${processDefinitionKeyVar}' }, + expect: { status: 200 }, + extract: [{ fieldPath: 'processInstanceKey', bind: 'processInstanceKeyVar' }], + }, + ], + }, + ], +}; + +const PATH_PARAM_COLLECTION: EndpointScenarioCollection = { + endpoint: { + operationId: 'cancelProcessInstance', + method: 'POST', + path: '/process-instances/{processInstanceKey}/cancellation', + }, + requiredSemanticTypes: ['ProcessInstanceKey'], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc1', + name: 'cancel instance', + operations: [ + { + operationId: 'cancelProcessInstance', + method: 'POST', + path: '/process-instances/{processInstanceKey}/cancellation', + }, + ], + producedSemanticTypes: [], + satisfiedSemanticTypes: ['ProcessInstanceKey'], + bindings: { processInstanceKeyVar: '__PENDING__' }, + requestPlan: [ + { + operationId: 'cancelProcessInstance', + method: 'POST', + pathTemplate: '/process-instances/{processInstanceKey}/cancellation', + pathParams: [{ name: 'processInstanceKey', var: 'processInstanceKeyVar' }], + expect: { status: 200 }, + }, + ], + }, + ], +}; + +// --------------------------------------------------------------------------- +// sdk-mapping: OperationMapJsonSource +// --------------------------------------------------------------------------- + +describe('OperationMapJsonSource', () => { + it('resolves a known operationId to the camelCased region', () => { + const src = OperationMapJsonSource.fromJson( + JSON.stringify({ + createDeployment: [ + { file: 'deployment.ts', region: 'DeployResourcesFromFiles', label: 'Deploy' }, + ], + }), + ); + expect(src.resolveMethod('createDeployment')).toBe('deployResourcesFromFiles'); + }); + + it('falls back to operationId when no mapping entry exists', () => { + const src = OperationMapJsonSource.fromJson(JSON.stringify({})); + expect(src.resolveMethod('unknownOp')).toBe('unknownOp'); + }); + + it('returns knownOperationIds matching the keys of the map', () => { + const src = OperationMapJsonSource.fromJson( + JSON.stringify({ + getTopology: [{ file: 'client.ts', region: 'GetTopology', label: 'Topology' }], + createUser: [{ file: 'user.ts', region: 'CreateUser', label: 'User' }], + }), + ); + expect(src.knownOperationIds().sort()).toEqual(['createUser', 'getTopology']); + }); + + it('picks the FIRST entry when multiple variants exist', () => { + const src = OperationMapJsonSource.fromJson( + JSON.stringify({ + createProcessInstance: [ + { file: 'process-instance.ts', region: 'CreateProcessInstanceById', label: 'By ID' }, + { file: 'process-instance.ts', region: 'CreateProcessInstanceByKey', label: 'By key' }, + ], + }), + ); + // First entry wins: "CreateProcessInstanceById" → "createProcessInstanceById" + expect(src.resolveMethod('createProcessInstance')).toBe('createProcessInstanceById'); + }); +}); + +// --------------------------------------------------------------------------- +// FallbackMappingSource +// --------------------------------------------------------------------------- + +describe('FallbackMappingSource', () => { + it('returns the operationId unchanged', () => { + expect(FALLBACK_MAPPING.resolveMethod('createWidget')).toBe('createWidget'); + }); + + it('returns an empty knownOperationIds list', () => { + expect(FALLBACK_MAPPING.knownOperationIds()).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// jsSdkSuiteFileName +// --------------------------------------------------------------------------- + +describe('jsSdkSuiteFileName', () => { + it('produces a .test.ts file in feature mode', () => { + expect(jsSdkSuiteFileName(MINIMAL_COLLECTION, 'feature')).toBe('getTopology.feature.test.ts'); + }); + + it('produces a .test.ts file in variant mode', () => { + expect(jsSdkSuiteFileName(MINIMAL_COLLECTION, 'variant')).toBe('getTopology.variant.test.ts'); + }); +}); + +// --------------------------------------------------------------------------- +// renderJsSdkSuite — suite preamble +// --------------------------------------------------------------------------- + +describe('renderJsSdkSuite — preamble', () => { + it('emits vitest imports (not Playwright)', () => { + const src = renderJsSdkSuite(MINIMAL_COLLECTION, FALLBACK_MAPPING, {}); + expect(src).toContain("import { describe, test } from 'vitest'"); + expect(src).not.toContain('@playwright/test'); + }); + + it('imports createCamundaClient from the SDK package', () => { + const src = renderJsSdkSuite(MINIMAL_COLLECTION, FALLBACK_MAPPING, {}); + expect(src).toContain("import createCamundaClient from '@camunda8/orchestration-cluster-api'"); + }); + + it('imports seeding utilities from ./support/seeding', () => { + const src = renderJsSdkSuite(MINIMAL_COLLECTION, FALLBACK_MAPPING, {}); + expect(src).toContain("import { extractInto, seedBinding } from './support/seeding'"); + }); + + it('creates a shared client at module scope', () => { + const src = renderJsSdkSuite(MINIMAL_COLLECTION, FALLBACK_MAPPING, {}); + expect(src).toContain('const client = createCamundaClient()'); + }); + + it('wraps scenarios in a describe block keyed by suiteName', () => { + const src = renderJsSdkSuite(MINIMAL_COLLECTION, FALLBACK_MAPPING, { + suiteName: 'getTopology', + }); + expect(src).toContain("describe('getTopology'"); + }); +}); + +// --------------------------------------------------------------------------- +// renderJsSdkSuite — no-arg operation +// --------------------------------------------------------------------------- + +describe('renderJsSdkSuite — no-arg operation', () => { + it('emits client.() with no args when no body or path params', () => { + const noArgCollection: EndpointScenarioCollection = { + ...MINIMAL_COLLECTION, + scenarios: [ + { + ...MINIMAL_COLLECTION.scenarios[0], + requestPlan: [ + { + operationId: 'getTopology', + method: 'GET', + pathTemplate: '/topology', + expect: { status: 200 }, + }, + ], + }, + ], + }; + const src = renderJsSdkSuite(noArgCollection, FALLBACK_MAPPING, {}); + expect(src).toContain('client.getTopology()'); + }); +}); + +// --------------------------------------------------------------------------- +// renderJsSdkSuite — JSON body +// --------------------------------------------------------------------------- + +describe('renderJsSdkSuite — JSON body', () => { + it('emits client.(args) with body fields resolved from ctx', () => { + const src = renderJsSdkSuite(JSON_BODY_COLLECTION, FALLBACK_MAPPING, {}); + expect(src).toContain('client.createProcessDefinition(args1)'); + expect(src).toContain('ctx["processDefNameVar"]'); + }); + + it('emits extract calls from the typed response (no .json())', () => { + const src = renderJsSdkSuite(JSON_BODY_COLLECTION, FALLBACK_MAPPING, {}); + expect(src).toContain("extractInto(ctx, 'processDefinitionKeyVar', result1.key)"); + expect(src).not.toContain('.json()'); + }); + + it('uses the mapped method symbol from the operation-map', () => { + const mapping = OperationMapJsonSource.fromJson( + JSON.stringify({ + createProcessDefinition: [ + { file: 'def.ts', region: 'CreateProcessDefinitionById', label: 'By ID' }, + ], + createProcessInstance: [ + { file: 'pi.ts', region: 'CreateProcessInstanceById', label: 'By ID' }, + ], + }), + ); + const src = renderJsSdkSuite(JSON_BODY_COLLECTION, mapping, {}); + expect(src).toContain('client.createProcessDefinitionById(args1)'); + expect(src).toContain('client.createProcessInstanceById(args2)'); + }); +}); + +// --------------------------------------------------------------------------- +// renderJsSdkSuite — path parameters +// --------------------------------------------------------------------------- + +describe('renderJsSdkSuite — path parameters', () => { + it('includes path parameters in the args object', () => { + const src = renderJsSdkSuite(PATH_PARAM_COLLECTION, FALLBACK_MAPPING, {}); + expect(src).toContain("processInstanceKey: ctx['processInstanceKeyVar']"); + }); + + it('emits client.(args) (not a bare ctx lookup)', () => { + const src = renderJsSdkSuite(PATH_PARAM_COLLECTION, FALLBACK_MAPPING, {}); + expect(src).toContain('client.cancelProcessInstance(args1)'); + }); +}); + +// --------------------------------------------------------------------------- +// renderJsSdkSuite — bindings & seeding +// --------------------------------------------------------------------------- + +describe('renderJsSdkSuite — bindings', () => { + it('seeds __PENDING__ bindings via seedBinding()', () => { + const src = renderJsSdkSuite(PATH_PARAM_COLLECTION, FALLBACK_MAPPING, {}); + expect(src).toContain("seedBinding('processInstanceKeyVar')"); + }); + + it('emits literal bindings as ctx assignments', () => { + const withLiteral: EndpointScenarioCollection = { + ...MINIMAL_COLLECTION, + scenarios: [ + { + ...MINIMAL_COLLECTION.scenarios[0], + bindings: { tenantIdVar: 'my-tenant' }, + requestPlan: [ + { + operationId: 'getTopology', + method: 'GET', + pathTemplate: '/topology', + expect: { status: 200 }, + }, + ], + }, + ], + }; + const src = renderJsSdkSuite(withLiteral, FALLBACK_MAPPING, {}); + expect(src).toContain('ctx[\'tenantIdVar\'] = "my-tenant"'); + }); +}); + +// --------------------------------------------------------------------------- +// renderJsSdkSuite — multipart hard-fail +// --------------------------------------------------------------------------- + +describe('renderJsSdkSuite — multipart hard-fail', () => { + it('throws when a step has bodyKind=multipart', () => { + const multipartCollection: EndpointScenarioCollection = { + endpoint: { + operationId: 'createDeployment', + method: 'POST', + path: '/deployments', + }, + requiredSemanticTypes: [], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc1', + name: 'deploy resource', + operations: [{ operationId: 'createDeployment', method: 'POST', path: '/deployments' }], + producedSemanticTypes: [], + satisfiedSemanticTypes: [], + requestPlan: [ + { + operationId: 'createDeployment', + method: 'POST', + pathTemplate: '/deployments', + bodyKind: 'multipart', + multipartTemplate: { fields: {}, files: { resources: '@@FILE:bpmn/test.bpmn' } }, + expect: { status: 200 }, + }, + ], + }, + ], + }; + expect(() => renderJsSdkSuite(multipartCollection, FALLBACK_MAPPING, {})).toThrow(/multipart/); + }); +}); + +// --------------------------------------------------------------------------- +// JsSdkEmitter (Emitter contract) +// --------------------------------------------------------------------------- + +describe('JsSdkEmitter (Emitter contract)', () => { + const emitter = createJsSdkEmitter(); + + it('has id "js-sdk" and a descriptive name', () => { + expect(emitter.id).toBe('js-sdk'); + expect(emitter.name).toMatch(/javascript.*sdk/i); + }); + + it('returns one EmittedFile per collection with a .test.ts extension', async () => { + const files = await emitter.emit(MINIMAL_COLLECTION, { + outDir: '/unused', + suiteName: 'getTopology', + mode: 'feature', + }); + expect(files).toHaveLength(1); + expect(files[0].relativePath).toBe('getTopology.feature.test.ts'); + expect(files[0].relativePath).toBe(jsSdkSuiteFileName(MINIMAL_COLLECTION, 'feature')); + }); + + it('emit() is pure: does not touch the filesystem', async () => { + await expect( + emitter.emit(MINIMAL_COLLECTION, { + outDir: '/this/does/not/exist', + suiteName: 'getTopology', + mode: 'feature', + }), + ).resolves.toBeDefined(); + }); + + it('renderJsSdkSuite is byte-identical to the EmittedFile content', async () => { + const [file] = await emitter.emit(MINIMAL_COLLECTION, { + outDir: '/unused', + suiteName: 'getTopology', + mode: 'feature', + }); + const direct = renderJsSdkSuite(MINIMAL_COLLECTION, new FallbackMappingSource(), { + suiteName: 'getTopology', + mode: 'feature', + }); + expect(file.content).toBe(direct); + }); + + it('variant mode produces a .variant.test.ts file name', async () => { + const files = await emitter.emit(MINIMAL_COLLECTION, { + outDir: '/unused', + suiteName: 'getTopology', + mode: 'variant', + }); + expect(files[0].relativePath).toBe('getTopology.variant.test.ts'); + }); +}); diff --git a/tests/codegen/python-sdk-emitter.test.ts b/tests/codegen/python-sdk-emitter.test.ts new file mode 100644 index 00000000..c8876252 --- /dev/null +++ b/tests/codegen/python-sdk-emitter.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, test } from 'vitest'; +import { PythonSdkEmitter } from '../../materializer/src/python-sdk/emitter.js'; +import type { EndpointScenarioCollection, RequestStep } from '../../path-analyser/src/types.js'; + +/** + * Layer-2 Python SDK emitter purity test — green step + * + * Input: The minimal `EndpointScenarioCollection` from Layer-1 fixture + * Process: Run PythonSdkEmitter.emit() with identical inputs + * Output: Assert byte-for-byte match against golden reference + * + * This test proves the emitter is pure and deterministic: same input + * always produces identical output. The fixture can be regenerated with + * confidence that a matching input will produce a matching file. + */ + +/** + * Minimal activateJobs scenario (from Layer-1 fixture) + */ +const FIXTURE_ACTIVATE_JOBS: EndpointScenarioCollection = { + endpoint: { + operationId: 'activateJobs', + method: 'POST', + path: '/jobs/activate', + }, + requiredSemanticTypes: [], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc-activate-jobs-simple', + name: 'activate jobs and extract key', + description: 'Activates jobs of a given type and extracts the first job key', + operations: [{ operationId: 'activateJobs', method: 'POST', path: '/jobs/activate' }], + producedSemanticTypes: ['JobKey'], + satisfiedSemanticTypes: [], + bindings: { + workerType: 'MyWorkerType', + }, + requestPlan: [ + // biome-ignore lint/plugin: test-only cast at a fixture boundary — value is hand-crafted in the test + { + operationId: 'activateJobs', + method: 'POST', + pathTemplate: '/jobs/activate', + bodyTemplate: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional binding placeholder resolved by the Python SDK emitter + type: '${workerType}', + maxJobsToActivate: 1, + timeout: 30000, + }, + bodyKind: 'json', + expect: { status: 200 }, + extract: [ + { + fieldPath: 'jobs[0].key', + bind: 'jobKey', + semantic: 'JobKey', + note: 'Primary job key from response', + }, + ], + } as RequestStep, + ], + seedBindings: ['workerType'], + }, + ], +}; + +describe('PythonSdkEmitter Layer-2 purity test (green step)', () => { + test('emitter produces EmittedFile with correct relative path', async () => { + const files = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(files).toHaveLength(1); + expect(files[0].relativePath).toBe('activateJobs.python_sdk.spec.py'); + }); + + test('emitter is pure: does not touch filesystem (outDir is unused)', async () => { + // outDir is intentionally a non-existent path; emit() must not throw. + await expect( + PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/this/does/not/exist', + suiteName: 'activateJobs', + mode: 'feature', + }), + ).resolves.toBeDefined(); + }); + + test('emitted suite contains async def test_ function', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.content).toContain('async def test_'); + expect(file.content).toContain('CamundaAsyncClient'); + }); + + test('emitted suite contains fixture file header comment', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.content).toContain('# Test suite for activateJobs'); + expect(file.content).toContain('# This file is auto-generated'); + }); + + test('emitted suite contains necessary imports', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.content).toContain('from typing import Any'); + expect(file.content).toContain('import pytest'); + expect(file.content).toContain('from camunda.client import CamundaAsyncClient'); + expect(file.content).toContain('from camunda.models import ActivateJobsRequest'); + expect(file.content).toContain('from helper import extract_into, seedBinding'); + }); + + test('emitted suite contains @pytest.mark.asyncio decorator', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.content).toContain('@pytest.mark.asyncio'); + }); + + test('emitted test function has correct signature', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.content).toMatch( + /async def test_sc_activate_jobs_simple\(client: CamundaAsyncClient\)/, + ); + }); + + test('emitted test contains context dict initialization', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.content).toContain('ctx: dict[str, Any] = {}'); + }); + + test('emitted test contains seed bindings from scenario', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.content).toContain("ctx['workerType'] = 'MyWorkerType'"); + }); + + test('emitted test contains await client.() call', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.content).toContain('await client.activate_jobs('); + expect(file.content).toContain('ActivateJobsRequest.from_dict(request_body)'); + }); + + test('emitted test contains request body dict construction', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.content).toContain('request_body = {'); + expect(file.content).toContain("'type': ctx['workerType']"); + expect(file.content).toContain("'maxJobsToActivate': 1"); + expect(file.content).toContain("'timeout': 30000"); + }); + + test('emitted test contains plain assert result is not None', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.content).toContain('assert result is not None'); + }); + + test('emitted test contains extract_into() calls for response fields', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.content).toContain("extract_into(ctx, 'jobKey'"); + }); + + test('emitter throws on multipart bodyKind (hard-fail)', async () => { + const multipartScenario: EndpointScenarioCollection = { + ...FIXTURE_ACTIVATE_JOBS, + scenarios: [ + { + ...FIXTURE_ACTIVATE_JOBS.scenarios[0], + requestPlan: [ + // biome-ignore lint/plugin: test-only cast at a fixture boundary — value is hand-crafted in the test + { + operationId: 'createDeployment', + method: 'POST', + pathTemplate: '/deployments', + bodyKind: 'multipart', + multipartTemplate: { file: '@@FILE:test.bpmn' }, + expect: { status: 200 }, + } as RequestStep, + ], + }, + ], + }; + + await expect( + PythonSdkEmitter.emit(multipartScenario, { + outDir: '/unused', + suiteName: 'createDeployment', + mode: 'feature', + }), + ).rejects.toThrow('[PythonSdkEmitter] Hard-fail: multipart body'); + }); + + test('emitter produces deterministic output (same input = same output)', async () => { + const [file1] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + const [file2] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file1.content).toBe(file2.content); + expect(file1.relativePath).toBe(file2.relativePath); + }); + + test('emitted file does not contain external validation libraries', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.content).not.toMatch(/jsonschema|pydantic|deepdiff|validate/); + }); + + test('emitted test has correct file extension (.python_sdk.spec.py)', async () => { + const [file] = await PythonSdkEmitter.emit(FIXTURE_ACTIVATE_JOBS, { + outDir: '/unused', + suiteName: 'activateJobs', + mode: 'feature', + }); + + expect(file.relativePath).toMatch(/\.python_sdk\.spec\.py$/); + }); +}); diff --git a/tests/fixtures/planner/csharp-sdk-emitter.test.ts b/tests/fixtures/planner/csharp-sdk-emitter.test.ts new file mode 100644 index 00000000..9a406377 --- /dev/null +++ b/tests/fixtures/planner/csharp-sdk-emitter.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import { createCsharpEmitter } from '../../../materializer/src/csharp-sdk/emitter.ts'; +import type { EndpointScenarioCollection } from '../../../path-analyser/src/types.ts'; + +/** + * Layer-1 C# SDK emitter fixture. + * + * Hand-built minimal `EndpointScenarioCollection` paired with structural + * assertions on the emitted C# source. One `it` = one emitter property. + * + * These fixtures are the regression guard: a change to the emitter that breaks + * the generated contract surfaces as an exact failing assertion here rather + * than as a silent output diff. The scoping is class-level: the fixture + * proves structural invariants that hold for ALL collections, not just the + * named instance. + */ + +const CTX = { + outDir: '/unused', + suiteName: 'getTopology', + mode: 'feature' as const, +}; + +/** + * Minimal no-body scenario: getTopology (no prerequisites, no request body). + */ +const FIXTURE_GET_TOPOLOGY: EndpointScenarioCollection = { + endpoint: { operationId: 'getTopology', method: 'GET', path: '/topology' }, + requiredSemanticTypes: [], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc-topology-simple', + name: 'get cluster topology', + description: 'Fetches the current Zeebe cluster topology', + operations: [{ operationId: 'getTopology', method: 'GET', path: '/topology' }], + producedSemanticTypes: [], + satisfiedSemanticTypes: [], + }, + ], +}; + +/** + * Two-step chain scenario: createDeployment → createProcessInstance. + * Exercises body-template resolution and response extraction across steps. + */ +const FIXTURE_CREATE_PROCESS_INSTANCE: EndpointScenarioCollection = { + endpoint: { + operationId: 'createProcessInstance', + method: 'POST', + path: '/process-instances', + }, + requiredSemanticTypes: ['ProcessDefinitionKey'], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc-create-pi', + name: 'create process instance', + operations: [ + { operationId: 'createDeployment', method: 'POST', path: '/deployments' }, + { operationId: 'createProcessInstance', method: 'POST', path: '/process-instances' }, + ], + producedSemanticTypes: ['ProcessInstanceKey'], + satisfiedSemanticTypes: ['ProcessDefinitionKey'], + requestPlan: [ + { + operationId: 'createDeployment', + method: 'POST', + pathTemplate: '/deployments', + bodyKind: 'multipart', + multipartTemplate: { + fields: {}, + files: { resource: '@@FILE:fixtures/bpmn/sample.bpmn' }, + }, + expect: { status: 200 }, + extract: [ + { + fieldPath: 'deployments[0].processDefinitionKey', + bind: 'processDefinitionKeyVar', + }, + ], + }, + { + operationId: 'createProcessInstance', + method: 'POST', + pathTemplate: '/process-instances', + bodyKind: 'json', + bodyTemplate: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional binding placeholder + processDefinitionKey: '${processDefinitionKeyVar}', + }, + expect: { status: 200 }, + extract: [{ fieldPath: 'processInstanceKey', bind: 'processInstanceKeyVar' }], + }, + ], + }, + ], +}; + +describe('CsharpSdkEmitter Layer-1 fixture: emitter identity', () => { + it('emitter id is stable identifier csharp-sdk', () => { + const emitter = createCsharpEmitter({}); + expect(emitter.id).toBe('csharp-sdk'); + }); + + it('emitter name contains "C# SDK"', () => { + const emitter = createCsharpEmitter({}); + expect(emitter.name).toMatch(/C# SDK/); + }); +}); + +describe('CsharpSdkEmitter Layer-1 fixture: file path contract', () => { + it('emit() returns exactly one EmittedFile per collection', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(FIXTURE_GET_TOPOLOGY, CTX); + expect(files).toHaveLength(1); + }); + + it('emitted file relativePath starts with csharp/', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(FIXTURE_GET_TOPOLOGY, CTX); + expect(files[0].relativePath).toMatch(/^csharp\//); + }); + + it('emitted file relativePath is csharp/..cs', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(FIXTURE_GET_TOPOLOGY, CTX); + expect(files[0].relativePath).toBe('csharp/getTopology.feature.cs'); + }); + + it('emitted file path uses operationId from the collection endpoint, not suiteName override', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(FIXTURE_CREATE_PROCESS_INSTANCE, { + ...CTX, + suiteName: 'createProcessInstance', + }); + expect(files[0].relativePath).toBe('csharp/createProcessInstance.feature.cs'); + }); +}); + +describe('CsharpSdkEmitter Layer-1 fixture: C# source skeleton', () => { + it('emitted source starts with using System; import', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(FIXTURE_GET_TOPOLOGY, CTX); + expect(files[0].content).toMatch(/^using System;/); + }); + + it('emitted source uses the Camunda.Orchestration.RestSdk.Generated namespace', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(FIXTURE_GET_TOPOLOGY, CTX); + expect(files[0].content).toContain('namespace Camunda.Orchestration.RestSdk.Generated'); + }); + + it('emitted source declares public static class GeneratedSuite', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(FIXTURE_GET_TOPOLOGY, CTX); + expect(files[0].content).toContain('public static class GeneratedSuite'); + }); + + it('emitted source contains a PascalCase async Task method named after the suiteName', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(FIXTURE_GET_TOPOLOGY, CTX); + expect(files[0].content).toContain('GetTopologyAsync'); + }); + + it('emitted source creates an OrchestrationClusterClient', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(FIXTURE_GET_TOPOLOGY, CTX); + expect(files[0].content).toContain('OrchestrationClusterClient'); + }); +}); + +describe('CsharpSdkEmitter Layer-1 fixture: chain scenario', () => { + it('fixture has correct two-step requestPlan for createDeployment → createProcessInstance', () => { + const scenario = FIXTURE_CREATE_PROCESS_INSTANCE.scenarios[0]; + expect(scenario.requestPlan).toHaveLength(2); + expect(scenario.requestPlan?.[0].operationId).toBe('createDeployment'); + expect(scenario.requestPlan?.[1].operationId).toBe('createProcessInstance'); + }); + + it('emitted chain output mentions createDeployment as a step', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(FIXTURE_CREATE_PROCESS_INSTANCE, { + ...CTX, + suiteName: 'createProcessInstance', + }); + expect(files[0].content).toContain('createDeployment'); + }); + + it('emitted chain output mentions createProcessInstance as a step', async () => { + const emitter = createCsharpEmitter({}); + const files = await emitter.emit(FIXTURE_CREATE_PROCESS_INSTANCE, { + ...CTX, + suiteName: 'createProcessInstance', + }); + expect(files[0].content).toContain('createProcessInstance'); + }); +}); diff --git a/tests/fixtures/planner/js-sdk-emitter.test.ts b/tests/fixtures/planner/js-sdk-emitter.test.ts new file mode 100644 index 00000000..02664877 --- /dev/null +++ b/tests/fixtures/planner/js-sdk-emitter.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; +import { jsSdkSuiteFileName, renderJsSdkSuite } from '../../../materializer/src/js-sdk/emitter.ts'; +import { FallbackMappingSource } from '../../../materializer/src/js-sdk/sdk-mapping.ts'; +import type { EndpointScenarioCollection } from '../../../path-analyser/src/types.ts'; + +/** + * Layer-1 JS SDK emitter fixture. + * + * Hand-built minimal `EndpointScenarioCollection` paired with structural + * assertions on the emitted Vitest source. One `it` = one emitter property. + * + * These fixtures are the regression guard: a change to the emitter that breaks + * the generated contract surfaces as an exact failing assertion here rather + * than as a silent output diff. The scoping is class-level: the fixture + * proves structural invariants that hold for ALL collections, not just the + * named instance. + */ + +const FALLBACK_MAPPING = new FallbackMappingSource(); + +/** + * Minimal no-body scenario: getTopology (no prerequisites, no request body). + */ +const FIXTURE_GET_TOPOLOGY: EndpointScenarioCollection = { + endpoint: { operationId: 'getTopology', method: 'GET', path: '/topology' }, + requiredSemanticTypes: [], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc-topology-simple', + name: 'get cluster topology', + description: 'Fetches the current Zeebe cluster topology', + operations: [{ operationId: 'getTopology', method: 'GET', path: '/topology' }], + producedSemanticTypes: [], + satisfiedSemanticTypes: [], + }, + ], +}; + +/** + * JSON body scenario: activateJobs (POST with request body and response extraction). + */ +const FIXTURE_ACTIVATE_JOBS: EndpointScenarioCollection = { + endpoint: { + operationId: 'activateJobs', + method: 'POST', + path: '/jobs/activate', + }, + requiredSemanticTypes: [], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc-activate-jobs-simple', + name: 'activate jobs and extract key', + description: 'Activates jobs of a given type and extracts the first job key', + operations: [{ operationId: 'activateJobs', method: 'POST', path: '/jobs/activate' }], + producedSemanticTypes: ['JobKey'], + satisfiedSemanticTypes: [], + bindings: { workerType: 'MyWorkerType' }, + requestPlan: [ + { + operationId: 'activateJobs', + method: 'POST', + pathTemplate: '/jobs/activate', + bodyTemplate: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional binding placeholder + type: '${workerType}', + maxJobsToActivate: 1, + timeout: 30000, + }, + bodyKind: 'json', + expect: { status: 200 }, + extract: [ + { + fieldPath: 'jobs[0].key', + bind: 'jobKey', + semantic: 'JobKey', + note: 'Primary job key from response', + }, + ], + }, + ], + seedBindings: ['workerType'], + }, + ], +}; + +describe('JsSdkEmitter Layer-1 fixture: filename contract', () => { + it('jsSdkSuiteFileName returns .feature.test.ts in feature mode', () => { + expect(jsSdkSuiteFileName(FIXTURE_GET_TOPOLOGY, 'feature')).toBe('getTopology.feature.test.ts'); + }); + + it('jsSdkSuiteFileName returns .integration.test.ts in integration mode', () => { + expect(jsSdkSuiteFileName(FIXTURE_GET_TOPOLOGY, 'integration')).toBe( + 'getTopology.integration.test.ts', + ); + }); + + it('jsSdkSuiteFileName uses operationId from the collection endpoint (not suiteName)', () => { + expect(jsSdkSuiteFileName(FIXTURE_ACTIVATE_JOBS, 'feature')).toBe( + 'activateJobs.feature.test.ts', + ); + }); + + it('jsSdkSuiteFileName uses .test.ts suffix — not .spec.ts (Playwright convention)', () => { + const name = jsSdkSuiteFileName(FIXTURE_GET_TOPOLOGY, 'feature'); + expect(name).toMatch(/\.test\.ts$/); + expect(name).not.toMatch(/\.spec\.ts$/); + }); +}); + +describe('JsSdkEmitter Layer-1 fixture: suite skeleton', () => { + it('emitted suite imports createCamundaClient from the SDK package', () => { + const src = renderJsSdkSuite(FIXTURE_GET_TOPOLOGY, FALLBACK_MAPPING, { mode: 'feature' }); + expect(src).toContain("import createCamundaClient from '@camunda8/orchestration-cluster-api'"); + }); + + it('emitted suite imports extractInto and seedBinding from ./support/seeding', () => { + const src = renderJsSdkSuite(FIXTURE_GET_TOPOLOGY, FALLBACK_MAPPING, { mode: 'feature' }); + expect(src).toContain("import { extractInto, seedBinding } from './support/seeding'"); + }); + + it('emitted suite wraps all scenarios in a describe block named after the operationId', () => { + const src = renderJsSdkSuite(FIXTURE_GET_TOPOLOGY, FALLBACK_MAPPING, { mode: 'feature' }); + expect(src).toMatch(/describe\('getTopology'/); + }); + + it('emitted suite wraps each scenario in a test() block', () => { + const src = renderJsSdkSuite(FIXTURE_GET_TOPOLOGY, FALLBACK_MAPPING, { mode: 'feature' }); + expect(src).toMatch(/test\(/); + }); + + it('emitted suite creates a shared client via createCamundaClient()', () => { + const src = renderJsSdkSuite(FIXTURE_GET_TOPOLOGY, FALLBACK_MAPPING, { mode: 'feature' }); + expect(src).toContain('const client = createCamundaClient()'); + }); +}); + +describe('JsSdkEmitter Layer-1 fixture: no-body scenario (getTopology)', () => { + it('no-body scenario with no requestPlan emits a // No request plan available comment', () => { + const src = renderJsSdkSuite(FIXTURE_GET_TOPOLOGY, FALLBACK_MAPPING, { mode: 'feature' }); + expect(src).toContain('// No request plan available'); + }); + + it('no-body scenario does not emit an args object', () => { + const src = renderJsSdkSuite(FIXTURE_GET_TOPOLOGY, FALLBACK_MAPPING, { mode: 'feature' }); + expect(src).not.toContain('const args1'); + }); +}); + +describe('JsSdkEmitter Layer-1 fixture: JSON body scenario (activateJobs)', () => { + it('activateJobs emits ctx seed for workerType binding from scenario.bindings', () => { + const src = renderJsSdkSuite(FIXTURE_ACTIVATE_JOBS, FALLBACK_MAPPING, { mode: 'feature' }); + expect(src).toContain("ctx['workerType']"); + }); + + // biome-ignore lint/suspicious/noTemplateCurlyInString: test description documents that ${workerType} placeholder is resolved to ctx["workerType"] + it('activateJobs body template ${workerType} resolves to ctx["workerType"] in the args object', () => { + const src = renderJsSdkSuite(FIXTURE_ACTIVATE_JOBS, FALLBACK_MAPPING, { mode: 'feature' }); + expect(src).toContain('ctx["workerType"]'); + }); + + it('activateJobs emits await client.activateJobs(args1)', () => { + const src = renderJsSdkSuite(FIXTURE_ACTIVATE_JOBS, FALLBACK_MAPPING, { mode: 'feature' }); + expect(src).toContain('client.activateJobs(args1)'); + }); + + it('activateJobs emits extractInto call for jobKey extraction', () => { + const src = renderJsSdkSuite(FIXTURE_ACTIVATE_JOBS, FALLBACK_MAPPING, { mode: 'feature' }); + expect(src).toContain("extractInto(ctx, 'jobKey'"); + }); + + // biome-ignore lint/suspicious/noTemplateCurlyInString: test description references ${...} syntax as a literal example of unresolved placeholders + it('emitted suite contains no unresolved ${...} placeholder strings (Bug A guard)', () => { + const src = renderJsSdkSuite(FIXTURE_ACTIVATE_JOBS, FALLBACK_MAPPING, { mode: 'feature' }); + expect(src).not.toMatch(/\$\{[^}]+\}/); + }); + + it('fixture scenario bindings and seedBindings are aligned', () => { + const scenario = FIXTURE_ACTIVATE_JOBS.scenarios[0]; + const seedBindings = scenario.seedBindings ?? []; + const bindings = scenario.bindings ?? {}; + for (const name of seedBindings) { + expect(bindings).toHaveProperty(name); + } + }); +}); diff --git a/tests/fixtures/planner/python-sdk-emitter.test.ts b/tests/fixtures/planner/python-sdk-emitter.test.ts new file mode 100644 index 00000000..affd692a --- /dev/null +++ b/tests/fixtures/planner/python-sdk-emitter.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; +import type { EndpointScenarioCollection, RequestStep } from '../../../path-analyser/src/types.ts'; + +/** + * Layer-1 Python SDK emitter fixture — red step + * + * Hand-built minimal `EndpointScenarioCollection` for a simple endpoint + * paired with the expected golden Python test file output. + * + * This fixture proves that when PythonSdkEmitter.emit() is implemented, + * it produces a byte-identical `.py` file matching the golden output. + * + * The test deliberately FAILS until the emitter is implemented (red step + * in red/green/class-scoped discipline). Layer-2 will depend on this + * fixture to verify emitter purity via byte-comparison. + */ + +/** + * Golden fixture: minimal activateJobs scenario (no prerequisites) + * + * Scenario structure: + * - Endpoint: activateJobs (POST /jobs/activate) + * - Request body: { type: "MyWorkerType" } + * - Response: activateJobsResponse + * - Extract: jobs[0].key as jobKey + */ +const FIXTURE_ACTIVATE_JOBS: EndpointScenarioCollection = { + endpoint: { + operationId: 'activateJobs', + method: 'POST', + path: '/jobs/activate', + }, + requiredSemanticTypes: [], + optionalSemanticTypes: [], + scenarios: [ + { + id: 'sc-activate-jobs-simple', + name: 'activate jobs and extract key', + description: 'Activates jobs of a given type and extracts the first job key', + operations: [{ operationId: 'activateJobs', method: 'POST', path: '/jobs/activate' }], + producedSemanticTypes: ['JobKey'], + satisfiedSemanticTypes: [], + bindings: { + workerType: 'MyWorkerType', + }, + requestPlan: [ + // biome-ignore lint/plugin: test-only cast at a fixture boundary — value is hand-crafted in the fixture + { + operationId: 'activateJobs', + method: 'POST', + pathTemplate: '/jobs/activate', + bodyTemplate: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional binding placeholder + type: '${workerType}', + maxJobsToActivate: 1, + timeout: 30000, + }, + bodyKind: 'json', + expect: { status: 200 }, + extract: [ + { + fieldPath: 'jobs[0].key', + bind: 'jobKey', + semantic: 'JobKey', + note: 'Primary job key from response', + }, + ], + } as RequestStep, + ], + seedBindings: ['workerType'], + }, + ], +}; + +/** + * Expected golden Python test file output + * + * This is the byte-for-byte output that PythonSdkEmitter.emit() should + * produce when given FIXTURE_ACTIVATE_JOBS. The fixture proves the emitter + * works correctly when this matches. + * + * Key patterns to verify: + * - async def test__(client) + * - ctx initialization and seed binding + * - from_dict() for request body model instantiation + * - await client.() call + * - Response field extraction via extract_into() helper + * - Plain assert result is not None smoke test + */ +const GOLDEN_ACTIVATE_JOBS_PY = `# Test for activateJobs +# This file is auto-generated. Do not edit. + +from typing import Any +import pytest +from camunda.client import CamundaAsyncClient +from helper import extract_into, seedBinding + + +@pytest.mark.asyncio +async def test_sc_activate_jobs_simple(client: CamundaAsyncClient) -> None: + """Activates jobs of a given type and extracts the first job key""" + + ctx: dict[str, Any] = {} + + # Seed scenario bindings + ctx['workerType'] = 'MyWorkerType' + + # Seed runtime-generated bindings + if 'workerType' not in ctx: + ctx['workerType'] = seedBinding('workerType') + + # Step 1: activateJobs + request_body = { + 'type': ctx['workerType'], + 'maxJobsToActivate': 1, + 'timeout': 30000 + } + result = await client.activate_jobs(data=ActivateJobsRequest.from_dict(request_body)) + assert result is not None, 'activateJobs must return a response' + + # Extract response fields + extract_into(ctx, 'jobKey', result['jobs'][0]['key']) +`; + +describe('PythonSdkEmitter Layer-1 fixture (red step)', () => { + it('fixture has correct scenario structure for activateJobs', () => { + expect(FIXTURE_ACTIVATE_JOBS.endpoint.operationId).toBe('activateJobs'); + expect(FIXTURE_ACTIVATE_JOBS.scenarios).toHaveLength(1); + const scenario = FIXTURE_ACTIVATE_JOBS.scenarios[0]; + expect(scenario.requestPlan).toHaveLength(1); + expect(scenario.requestPlan?.[0]?.operationId).toBe('activateJobs'); + expect(scenario.requestPlan?.[0]?.extract).toHaveLength(1); + }); + + it('fixture scenario has seed binding for workerType', () => { + const scenario = FIXTURE_ACTIVATE_JOBS.scenarios[0]; + expect(scenario.seedBindings).toContain('workerType'); + expect(scenario.bindings?.workerType).toBe('MyWorkerType'); + }); + + it('fixture request step uses json bodyKind (not multipart)', () => { + const step = FIXTURE_ACTIVATE_JOBS.scenarios[0].requestPlan?.[0]; + expect(step?.bodyKind).toBe('json'); + expect(step?.multipartTemplate).toBeUndefined(); + }); + + it('golden Python output contains required async def test signature', () => { + expect(GOLDEN_ACTIVATE_JOBS_PY).toMatch( + /^async def test_sc_activate_jobs_simple\(client: CamundaAsyncClient\) -> None:/m, + ); + }); + + it('golden output contains from_dict() call (not raw dict)', () => { + expect(GOLDEN_ACTIVATE_JOBS_PY).toContain('ActivateJobsRequest.from_dict(request_body)'); + }); + + it('golden output contains await client.() call', () => { + expect(GOLDEN_ACTIVATE_JOBS_PY).toContain('await client.activate_jobs('); + }); + + it('golden output contains plain assert result is not None (SDK throws on error)', () => { + expect(GOLDEN_ACTIVATE_JOBS_PY).toContain( + "assert result is not None, 'activateJobs must return a response'", + ); + }); + + it('golden output contains extract_into() helper for response fields', () => { + expect(GOLDEN_ACTIVATE_JOBS_PY).toContain("extract_into(ctx, 'jobKey',"); + }); + + it('golden output does not use external validation libs (no jsonschema, pydantic, deepdiff)', () => { + expect(GOLDEN_ACTIVATE_JOBS_PY).not.toMatch(/jsonschema|pydantic|deepdiff/); + }); + + it('golden output contains seed binding call for workerType', () => { + expect(GOLDEN_ACTIVATE_JOBS_PY).toContain("seedBinding('workerType')"); + }); + + it('fixture scenario bindings and seedBindings are aligned', () => { + const scenario = FIXTURE_ACTIVATE_JOBS.scenarios[0]; + const seedBindings = scenario.seedBindings ?? []; + const bindings = scenario.bindings ?? {}; + for (const name of seedBindings) { + expect(bindings).toHaveProperty(name); + } + }); + + it('fixture has correct request body template with placeholders', () => { + const step = FIXTURE_ACTIVATE_JOBS.scenarios[0].requestPlan?.[0]; + expect(step?.bodyTemplate).toEqual({ + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional binding placeholder + type: '${workerType}', + maxJobsToActivate: 1, + timeout: 30000, + }); + }); +}); diff --git a/tests/regression/generated-suites-typecheck.test.ts b/tests/regression/generated-suites-typecheck.test.ts index 0c923f82..be696fa7 100644 --- a/tests/regression/generated-suites-typecheck.test.ts +++ b/tests/regression/generated-suites-typecheck.test.ts @@ -61,6 +61,7 @@ describe.each(SUITES)('emitted $label suite typechecks under strict mode', ({ const result = spawnSync('npx', ['--no-install', 'tsc', '--noEmit', '-p', tsconfig], { cwd: REPO_ROOT, encoding: 'utf8', + shell: true, }); if (result.error) { throw new Error(