Skip to content

Commit 84b4e18

Browse files
authored
feat: SDK Package test runner; allow servers to skip dynamic-typing, bandits (#95)
* Add spec for SDK details endpoint * Implement SDK Details in dotnet relay * sdk details supports bools instead of map WIP * separate params * exta _ * defaults and renames
1 parent 7aeb512 commit 84b4e18

File tree

13 files changed

+157
-56
lines changed

13 files changed

+157
-56
lines changed

package-testing/dotnet-sdk-relay/EppoSDKRelay/Controllers/AssignmentController.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,4 @@ public ActionResult<string> Post([FromBody] AssignmentRequest data)
4949

5050
return JsonError("Invalid Assignment Type " + data.AssignmentType);
5151
}
52-
53-
5452
}

package-testing/dotnet-sdk-relay/EppoSDKRelay/Controllers/JsonControllerBase.cs

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,16 @@
1-
using Microsoft.AspNetCore.Mvc;
2-
3-
using EppoSDKRelay.DTO;
41
using System.Text.Json;
5-
using Newtonsoft.Json.Linq;
62
using eppo_sdk.dto;
73
using eppo_sdk.dto.bandit;
4+
using EppoSDKRelay.DTO;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Newtonsoft.Json.Linq;
87

98
namespace EppoSDKRelay.controllers;
109

1110
public class JsonControllerBase : ControllerBase
1211
{
13-
14-
protected static readonly JsonSerializerOptions SerializeOptions = new()
15-
{
16-
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
17-
WriteIndented = true
18-
};
12+
protected static readonly JsonSerializerOptions SerializeOptions =
13+
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true };
1914

2015
protected static ActionResult<string> JsonTestResponse(object result)
2116
{
@@ -30,18 +25,29 @@ protected static ActionResult<string> JsonTestResponse(object result)
3025
var response = new TestResponse
3126
{
3227
Result = result,
33-
AssignmentLog = DequeueAllForResponse<AssignmentLogData>(AssignmentLogger.Instance.AssignmentLogs),
34-
BanditLog = DequeueAllForResponse<BanditLogEvent>(AssignmentLogger.Instance.BanditLogs),
28+
AssignmentLog = DequeueAllForResponse<AssignmentLogData>(
29+
AssignmentLogger.Instance.AssignmentLogs
30+
),
31+
BanditLog = DequeueAllForResponse<BanditLogEvent>(AssignmentLogger.Instance.BanditLogs),
3532
};
3633
return JsonSerializer.Serialize(response, SerializeOptions);
3734
}
3835

39-
protected static ActionResult<string> JsonError(String error)
36+
protected static ActionResult<string> JsonObjectResponse(object result)
4037
{
41-
return JsonSerializer.Serialize(new TestResponse
38+
// System.Text.Json does not play nicely with Newtonsoft types
39+
// Since "Objects" implement IEnumerable, System.Text will try to encode
40+
// the json object as an array. :(
41+
if (result is JObject)
4242
{
43-
Error = error
44-
}, SerializeOptions);
43+
result = ((JObject)result).ToObject<Dictionary<string, object>>();
44+
}
45+
return JsonSerializer.Serialize(result, SerializeOptions);
46+
}
47+
48+
protected static ActionResult<string> JsonError(String error)
49+
{
50+
return JsonSerializer.Serialize(new TestResponse { Error = error }, SerializeOptions);
4551
}
4652

4753
public static List<object> DequeueAllForResponse<T>(Queue<T> queue)
Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
using eppo_sdk;
2+
using eppo_sdk.helpers;
23
using Microsoft.AspNetCore.Mvc;
34

5+
46
namespace EppoSDKRelay.controllers;
57

6-
[Route("/sdk/reset")]
7-
public class SDKController : ControllerBase
8+
public class SDKController : JsonControllerBase
89
{
910
// POST sdk/reset
11+
[Route("/sdk/reset")]
1012
[HttpPost]
1113
public ActionResult<IEnumerable<string>> Reset()
1214
{
@@ -15,4 +17,23 @@ public ActionResult<IEnumerable<string>> Reset()
1517
return Ok();
1618
}
1719

20+
// GET sdk/details
21+
[Route("/sdk/details")]
22+
[HttpGet]
23+
public ActionResult<String> Details()
24+
{
25+
// Sneak the SDK version and name from the AppDetails object
26+
var apd = new AppDetails();
27+
var sdkDetails = new Dictionary<string, object>
28+
{
29+
["sdkVersion"] = apd.Version,
30+
["sdkName"] = apd.Name,
31+
["supportsBandits"] = true,
32+
["supportsDynamicTyping"] = true
33+
};
34+
35+
return JsonObjectResponse(
36+
sdkDetails
37+
);
38+
}
1839
}

package-testing/dotnet-sdk-relay/EppoSDKRelay/Startup.cs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,34 @@ namespace EppoSDKRelay;
55

66
public class Startup
77
{
8-
static readonly String eppoBaseUrl = Environment.GetEnvironmentVariable("EPPO_BASE_URL") ?? "http://localhost:5000/api";
9-
static readonly String apiToken = Environment.GetEnvironmentVariable("EPPO_API_TOKEN") ?? "NO_TOKEN";
10-
8+
static readonly String eppoBaseUrl =
9+
Environment.GetEnvironmentVariable("EPPO_BASE_URL") ?? "http://localhost:5000/api";
10+
static readonly String apiToken =
11+
Environment.GetEnvironmentVariable("EPPO_API_TOKEN") ?? "NO_TOKEN";
1112

1213
public static void InitEppoClient()
1314
{
1415
Console.WriteLine("Initializating SDK pointed at" + eppoBaseUrl);
1516

1617
var eppoClientConfig = new EppoClientConfig(apiToken, AssignmentLogger.Instance)
1718
{
18-
BaseUrl = eppoBaseUrl
19+
BaseUrl = eppoBaseUrl,
1920
};
2021

2122
EppoClient.Init(eppoClientConfig);
2223
}
2324

2425
public void ConfigureServices(IServiceCollection services)
2526
{
26-
2727
Startup.InitEppoClient();
2828

2929
services.AddControllers();
3030
services.AddSwaggerGen(c =>
3131
{
32-
c.SwaggerDoc("v1", new OpenApiInfo
33-
{ Title = "Eppo SDK Relay Server", Version = "v1" });
32+
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Eppo SDK Relay Server", Version = "v1" });
3433
});
3534
}
3635

37-
3836
// Startup.cs
3937
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
4038
{
@@ -54,7 +52,6 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
5452
app.UseEndpoints(endpoints =>
5553
{
5654
endpoints.MapControllers();
57-
5855
});
5956
}
6057
}

package-testing/sdk-test-runner/README.md

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,25 @@ The test runner sends assignment and bandit action requests to the SDK Relay Ser
199199

200200
Any non-empty response
201201

202+
##### SDK Details
203+
204+
`POST /sdk/details`
205+
206+
If possible, the SDK relay server should respond with the `sdkName` and `sdkVersion` in use. This may not be directly possible with all SDKs.
207+
If the SDK does not support Bandits or dynamic typing, the test runner will skip the related test cases if the corresponding values are `false`.
208+
209+
`GET /sdk/details`
210+
211+
```ts
212+
// Expected response data:
213+
type SDKDetailsResponse = {
214+
sdkName?: string;
215+
sdkVersion?: string;
216+
supportsBandits?: boolean;
217+
supportsDynamicTyping?: boolean;
218+
};
219+
```
220+
202221
##### Reset SDK
203222

204223
`POST /sdk/reset`
@@ -225,13 +244,13 @@ type Assignment = {
225244
subjectAttributes: Record<string, object>;
226245
};
227246

228-
// Expect response data:
229-
export type TestResponse {
230-
result?: Object, // Relayed `EppoClient` response
231-
assignmentLog?: Object[], // Assignment log events (not yet tested)
232-
banditLog?: Object[], // Bandit selection log events (not yet tested)
233-
error?: string // Error encountered (not yet tested; automatically fails test when present)
234-
}
247+
// Expected response data:
248+
type TestResponse = {
249+
result?: Object; // Relayed `EppoClient` response
250+
assignmentLog?: Object[]; // Assignment log events (not yet tested)
251+
banditLog?: Object[]; // Bandit selection log events (not yet tested)
252+
error?: string; // Error encountered (not yet tested; automatically fails test when present)
253+
};
235254
```
236255

237256
##### Bandits
@@ -264,12 +283,12 @@ export type BanditActionRequest = {
264283
};
265284

266285
// Expects response data:
267-
export type TestResponse {
268-
result?: Object, // Relayed `EppoClient` response, form of {variation: string, action: string}
269-
assignmentLog?: Object[], // Assignment log events (not yet tested)
270-
banditLog?: Object[], // Bandit selection log events (not yet tested)
271-
error?: string // Error encountered (not yet tested; automatically fails test when present)
272-
}
286+
type TestResponse = {
287+
result?: Object; // Relayed `EppoClient` response, form of {variation: string, action: string}
288+
assignmentLog?: Object[]; // Assignment log events (not yet tested)
289+
banditLog?: Object[]; // Bandit selection log events (not yet tested)
290+
error?: string; // Error encountered (not yet tested; automatically fails test when present)
291+
};
273292
```
274293

275294
### build-and-run.sh

package-testing/sdk-test-runner/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sdk-test-runner",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"description": "Test runner for SDK package testing",
55
"main": "src/app.ts",
66
"repository": "https://github.com/Eppo-exp/sdk-test-data",

package-testing/sdk-test-runner/src/app.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export default class App {
138138
suites: testSuites,
139139
};
140140
const junitXml = getJunitXml(testSuiteReport);
141-
fs.writeFileSync(junitFile, junitXml);
141+
fs.writeFileSync('./' + junitFile, junitXml);
142142

143143
log(green(`Test results written to ${junitFile}`));
144144
}
@@ -201,6 +201,14 @@ export default class App {
201201
for (const child of testCases) {
202202
const filePath = path.join(testCaseDir, child);
203203

204+
// Skip dynamic typing files if not supported
205+
if (!sdkRelay.getSDKDetails().supportsDynamicTyping && isDynamicTypingFile(filePath)) {
206+
logIndent(1, yellow('skipped') + ` ${child} SDK does not support dynamic typing`);
207+
const testCaseResult: TestCase = { name: child, classname: child, skipped: true };
208+
testCaseResults.push(testCaseResult);
209+
continue;
210+
}
211+
204212
// Skip directories.
205213
if (!fs.statSync(filePath).isFile()) {
206214
continue;
@@ -218,6 +226,14 @@ export default class App {
218226
// Flag testing!!
219227
const isFlagTest = testCaseObj['variationType'];
220228

229+
// Skip bandit tests if not supported
230+
if (!isFlagTest && !sdkRelay.getSDKDetails().supportsBandits) {
231+
logIndent(1, yellow('skipped') + ' SDK does not support Bandits');
232+
const testCaseResult: TestCase = { name: child, classname: child, skipped: true };
233+
testCaseResults.push(testCaseResult);
234+
continue;
235+
}
236+
221237
if (testCaseObj['subjects'].length === 0) {
222238
testCaseResults.push({ name: testCase, errors: [{ message: 'No test subjects found' }] });
223239
}
@@ -277,7 +293,7 @@ export default class App {
277293
.catch((error) => {
278294
if (error instanceof FeatureNotSupportedError) {
279295
// Skip this test
280-
logIndent(1, yellow('skipped') + ` ${testCaseLabel}: SDK does not support this feature`);
296+
logIndent(1, yellow('skipped') + ` ${testCaseLabel}: SDK does not support ${error.featureName}`);
281297
testCaseResult.skipped = true;
282298
} else {
283299
log(red('Error1:'), error);
@@ -299,3 +315,6 @@ export default class App {
299315
return isEqual(subject['assignment'], results['result']);
300316
}
301317
}
318+
function isDynamicTypingFile(filePath: string) {
319+
return filePath.indexOf('dynamic-typing') >= 0;
320+
}

package-testing/sdk-test-runner/src/protocol/ClientSDKRelay.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ export class ClientSDKRelay implements SDKRelay {
2121
private isReadyPromise: Promise<SDKInfo>;
2222
private ready = false;
2323
private socket?: Socket;
24-
private sdkInfo?: SDKInfo;
24+
25+
// Defaults for client SDKs
26+
private sdkInfo: SDKInfo = {
27+
supportsBandits: false,
28+
supportsDynamicTyping: true,
29+
sdkName: 'UNKNOWN',
30+
};
2531

2632
constructor(testrunnerPort: number = 3000) {
2733
// Run socketIO through a node http server.
@@ -41,7 +47,7 @@ export class ClientSDKRelay implements SDKRelay {
4147
socket.on('READY', (msg, ack) => {
4248
const msgObj = JSON.parse(msg);
4349

44-
this.sdkInfo = msgObj as SDKInfo;
50+
this.sdkInfo = { ...this.sdkInfo, ...(msgObj as SDKInfo) };
4551

4652
log(green(`Client ${this.sdkInfo?.sdkName} reports ready`));
4753

@@ -60,6 +66,12 @@ export class ClientSDKRelay implements SDKRelay {
6066
httpServer.listen(testrunnerPort);
6167
});
6268
}
69+
getSDKDetails(): SDKInfo {
70+
if (this.sdkInfo == null) {
71+
throw new Error('SDK Client is not connected');
72+
}
73+
return this.sdkInfo;
74+
}
6375

6476
reset(): Promise<void> {
6577
if (!this.ready) {
@@ -79,7 +91,7 @@ export class ClientSDKRelay implements SDKRelay {
7991
throw new Error('SDK Client is not connected');
8092
}
8193
if (!this.sdkInfo?.supportsBandits) {
82-
throw new FeatureNotSupportedError('Bandits are not supported in this SDK');
94+
throw new FeatureNotSupportedError('Bandits are not supported in this SDK', 'Bandits');
8395
}
8496

8597
const result = await new Promise((resolve) => {

package-testing/sdk-test-runner/src/protocol/FeatureNotSupportedError.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
export class FeatureNotSupportedError extends Error {
2-
constructor(message: string) {
2+
constructor(
3+
message: string,
4+
public readonly featureName: string,
5+
) {
36
super(message);
47
this.name = 'FeatureNotSupportedError';
58
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export type SDKInfo = {
22
sdkName: string;
3+
sdkVersion?: string;
34
supportsBandits: boolean;
5+
supportsDynamicTyping: boolean;
46
};

0 commit comments

Comments
 (0)