Skip to content

Commit c2b1860

Browse files
authored
test: Oxygen SDK contract tests (#993)
This PR will add in contract tests to the Oxygen SDK CI <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a new Shopify Oxygen contract test service and updates CI to build/run it and execute the SDK contract tests. > > - **Contract test service** (`packages/sdk/shopify-oxygen/contract-tests`): > - New Express-based service (`src/index.ts`) with basic endpoints to report capabilities, create/delete clients, and run `evaluate` commands via a simple `ClientPool` (`src/utils/clientPool.ts`). > - Build/config: `package.json` (tsup build, start), `tsconfig.json`, `tsup.config.ts`, `.gitignore`, helper script `run-test-harness.sh`. > - **Monorepo**: > - Adds workspace `packages/sdk/shopify-oxygen/contract-tests` in root `package.json`. > - **CI** (`.github/workflows/shopify-oxygen.yml`): > - Installs, builds, and launches the contract test service, then runs `launchdarkly/contract-tests` action against it on port `8000` with specified skipped suites. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit df01447. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 752db79 commit c2b1860

File tree

10 files changed

+296
-1
lines changed

10 files changed

+296
-1
lines changed

.github/workflows/shopify-oxygen.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,15 @@ jobs:
2626
with:
2727
workspace_name: '@launchdarkly/shopify-oxygen-sdk'
2828
workspace_path: packages/sdk/shopify-oxygen
29+
- name: Install contract test service dependencies
30+
run: yarn workspace @launchdarkly/shopify-oxygen-contract-tests install --no-immutable
31+
- name: Build the test service
32+
run: yarn workspace @launchdarkly/shopify-oxygen-contract-tests build
33+
- name: Launch the test service in the background
34+
run: yarn workspace @launchdarkly/shopify-oxygen-contract-tests start &> /dev/null &
35+
- uses: launchdarkly/gh-actions/actions/contract-tests@61ea6c63de800b495a2fe40c0d4e4ba2a2833ee6
36+
with:
37+
test_service_port: 8000
38+
token: ${{ secrets.GITHUB_TOKEN }}
39+
# Based on run-test-harness.sh from the sdk package
40+
extra_params: '--url http://localhost:8000 --skip "streaming.*" --skip "evaluation.*" --skip "event.*" --skip "service.*" --stop-service-at-end'

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
"packages/telemetry/browser-telemetry",
4343
"contract-tests",
4444
"packages/sdk/combined-browser",
45-
"packages/sdk/shopify-oxygen"
45+
"packages/sdk/shopify-oxygen",
46+
"packages/sdk/shopify-oxygen/contract-tests"
4647
],
4748
"private": true,
4849
"scripts": {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.log
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@launchdarkly/shopify-oxygen-contract-tests",
3+
"version": "0.0.0",
4+
"main": "dist/index.js",
5+
"scripts": {
6+
"start": "node --inspect dist/index.js",
7+
"build": "tsup",
8+
"dev": "tsc --watch"
9+
},
10+
"type": "module",
11+
"license": "Apache-2.0",
12+
"private": true,
13+
"dependencies": {
14+
"@launchdarkly/js-server-sdk-common": "workspace:^",
15+
"@launchdarkly/shopify-oxygen-sdk": "workspace:^",
16+
"express": "^5.0.1"
17+
},
18+
"devDependencies": {
19+
"@types/express": "^5.0.1",
20+
"@types/node": "^18.11.9",
21+
"tsup": "^8.5.0",
22+
"typescript": "^4.9.0"
23+
}
24+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/bin/bash
2+
3+
# If sdk-test-harness is not in the path, then you will need to set
4+
# the SDK_TEST_HARNESS environment variable to the path to the sdk-test-harness binary.
5+
6+
# Default to the local sdk-test-harness binary if not provided
7+
if [ -z "${SDK_TEST_HARNESS}" ]; then
8+
SDK_TEST_HARNESS="sdk-test-harness"
9+
fi
10+
11+
# Uncomment this to start the test service in the background
12+
# yarn start &> test-service.log &
13+
14+
# skipping all tests that require streaming connections.
15+
${SDK_TEST_HARNESS} --url http://localhost:8000 \
16+
--skip "streaming.*" \
17+
--skip "evaluation.*" \
18+
--skip "event.*" \
19+
--skip "service.*" \
20+
--stop-service-at-end
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import express, { Request, Response } from 'express';
2+
import { Server } from 'http';
3+
4+
import { ClientPool } from './utils';
5+
6+
/* eslint-disable no-console */
7+
8+
// export DEBUG=true to enable debugging
9+
// unset DEBUG to disable debugging
10+
const debugging = process.env.DEBUG === 'true';
11+
12+
const app = express();
13+
let server: Server | null = null;
14+
15+
app.use(express.json());
16+
17+
const port = 8000;
18+
19+
const clientPool = new ClientPool();
20+
21+
if (debugging) {
22+
app.use((req: Request, res: Response, next: Function) => {
23+
console.debug('request', req.method, req.url);
24+
if (req.body) {
25+
console.debug('request', JSON.stringify(req.body, null, 2));
26+
}
27+
next();
28+
});
29+
} else {
30+
// NOOP global console.debug
31+
console.debug = () => {};
32+
}
33+
34+
app.get('/', (req: Request, res: Response) => {
35+
res.header('Content-Type', 'application/json');
36+
res.json({
37+
capabilities: ['server-side-polling', 'server-side'],
38+
});
39+
});
40+
41+
app.delete('/', (req: Request, res: Response) => {
42+
console.log('Test service has told us to exit');
43+
res.status(204);
44+
res.send();
45+
46+
if (server) {
47+
server.close(() => process.exit());
48+
}
49+
});
50+
51+
app.post('/', async (req: Request, res: Response) => {
52+
await clientPool.createClient(req.body, res);
53+
});
54+
55+
app.post('/clients/:id', async (req: Request, res: Response) => {
56+
await clientPool.runCommand(req.params.id, req.body, res);
57+
});
58+
59+
app.delete('/clients/:id', async (req: Request, res: Response) => {
60+
console.debug('DELETE request received /clients/:id');
61+
console.debug(req.params.id);
62+
await clientPool.deleteClient(req.params.id, res);
63+
});
64+
65+
server = app.listen(port, () => {
66+
// eslint-disable-next-line no-console
67+
console.log('Listening on port %d', port);
68+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { Response } from 'express';
2+
3+
import { LDClient } from '@launchdarkly/js-server-sdk-common';
4+
import { init } from '@launchdarkly/shopify-oxygen-sdk';
5+
6+
/* eslint-disable no-console */
7+
8+
// NOTE: Currently, this is a very simple client pool that only really handles the
9+
// very limited Oxygen specific use cases... we should be expand this to be more
10+
// general purpose in the future and maybe even come up with some shared ts interface
11+
// to facilitate future contract testing.
12+
13+
// TODO: currently this class will handle the response sending as well, which may technically
14+
// sit outside the scope of what it SHOULD be doing. We should refactor this to be more
15+
// general purpose and allow the caller to handle the response sending.
16+
17+
/**
18+
* ClientPool is a singleton that manages a pool of LDClient instances. Currently there is
19+
* no separation between a managed client and this pool. Which means all of the client specs
20+
* will be implemented in this class.
21+
*
22+
* @see https://github.com/launchdarkly/sdk-test-harness/blob/v2/docs/service_spec.md
23+
*/
24+
export default class ClientPool {
25+
private _clients: Record<string, LDClient> = {};
26+
private _clientCounter = 0;
27+
28+
constructor() {
29+
this._clients = {};
30+
this._clientCounter = 0;
31+
}
32+
33+
private _makeId(): string {
34+
this._clientCounter += 1;
35+
return `client-${this._clientCounter}`;
36+
}
37+
38+
public async runCommand(id: string, body: any, res: Response): Promise<void> {
39+
const client = this._clients[id];
40+
// TODO: handle the 'itCanFailCase'
41+
if (client) {
42+
try {
43+
const { command, ...rest } = body;
44+
switch (command) {
45+
case 'evaluate': {
46+
const { flagKey, context, defaultValue, detail } = rest.evaluate;
47+
const evaluation = detail
48+
? await client.variationDetail(flagKey, context, defaultValue)
49+
: await client.variation(flagKey, context, defaultValue);
50+
res.status(200);
51+
res.json({ value: evaluation });
52+
break;
53+
}
54+
default: {
55+
res.status(400);
56+
res.json({ error: `Unknown command: ${command}` });
57+
break;
58+
}
59+
}
60+
} catch (err) {
61+
console.error(`Error running command: ${err}`);
62+
res.status(500);
63+
res.send();
64+
}
65+
} else {
66+
res.status(404);
67+
res.send();
68+
}
69+
}
70+
71+
public async deleteClient(id: string, res: Response): Promise<void> {
72+
const client = this._clients[id];
73+
if (client) {
74+
client.close();
75+
delete this._clients[id];
76+
res.status(204);
77+
res.send();
78+
} else {
79+
res.status(404);
80+
res.send();
81+
}
82+
}
83+
84+
public async createClient(options: any, res: Response): Promise<void> {
85+
try {
86+
const id = this._makeId();
87+
const {
88+
configuration: { credential = 'unknown-sdk-key', polling },
89+
} = options;
90+
91+
if (!polling) {
92+
// We do not support non-polling clients yet
93+
res.status(400);
94+
res.send();
95+
return;
96+
}
97+
const client = await init(credential, {
98+
...(polling && {
99+
baseUri: polling.baseUri,
100+
}),
101+
});
102+
103+
await client.waitForInitialization({ timeout: 10 });
104+
this._clients[id] = client;
105+
res.status(201);
106+
res.set('Location', `/clients/${id}`);
107+
if (!client.initialized()) {
108+
res.status(500);
109+
client.close();
110+
res.send();
111+
return;
112+
}
113+
console.debug(`Creating client with configuration: ${JSON.stringify(options.configuration)}`);
114+
res.send();
115+
} catch (err) {
116+
console.error(`Error creating client: ${err}`);
117+
res.status(500);
118+
res.send();
119+
}
120+
}
121+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as ClientPool } from './clientPool';
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"compilerOptions": {
3+
"allowSyntheticDefaultImports": true,
4+
"declaration": true,
5+
"declarationMap": true,
6+
"lib": ["es6"],
7+
"module": "ES6",
8+
"moduleResolution": "node",
9+
"noImplicitOverride": true,
10+
"outDir": "dist",
11+
"resolveJsonModule": true,
12+
"rootDir": ".",
13+
"skipLibCheck": true,
14+
"sourceMap": true,
15+
"strict": true,
16+
"stripInternal": true,
17+
"target": "ES2017",
18+
},
19+
"include": ["src/**/*"],
20+
"exclude": ["dist", "node_modules"]
21+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// It is a dev dependency and the linter doesn't understand.
2+
// @ts-ignore - tsup is a dev dependency installed at runtime
3+
// eslint-disable-next-line import/no-extraneous-dependencies
4+
import { defineConfig } from 'tsup';
5+
6+
export default defineConfig({
7+
entry: {
8+
index: 'src/index.ts',
9+
},
10+
minify: true,
11+
format: ['esm', 'cjs'],
12+
splitting: false,
13+
sourcemap: false,
14+
clean: true,
15+
dts: true,
16+
metafile: true,
17+
esbuildOptions(opts) {
18+
// This would normally be `^_(?!meta|_)`, but go doesn't support negative look-ahead assertions,
19+
// so we need to craft something that works without it.
20+
// So start of line followed by a character that isn't followed by m or underscore, but we
21+
// want other things that do start with m, so we need to progressively handle more characters
22+
// of meta with exclusions.
23+
// eslint-disable-next-line no-param-reassign
24+
opts.mangleProps = /^_([^m|_]|m[^e]|me[^t]|met[^a])/;
25+
},
26+
});

0 commit comments

Comments
 (0)