From 34d45e4df59550216fc9444d0f907a1d420f7142 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 12 Nov 2025 16:38:26 -0600 Subject: [PATCH 1/3] test: contrast tests for Oxygen sdk --- package.json | 3 +- .../shopify-oxygen/contract-tests/.gitignore | 1 + .../contract-tests/package.json | 24 ++++ .../contract-tests/run-test-harness.sh | 20 ++++ .../contract-tests/src/index.ts | 71 ++++++++++++ .../contract-tests/src/utils/clientPool.ts | 109 ++++++++++++++++++ .../contract-tests/src/utils/index.ts | 1 + .../contract-tests/tsconfig.json | 21 ++++ .../contract-tests/tsup.config.ts | 26 +++++ 9 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/shopify-oxygen/contract-tests/.gitignore create mode 100644 packages/sdk/shopify-oxygen/contract-tests/package.json create mode 100755 packages/sdk/shopify-oxygen/contract-tests/run-test-harness.sh create mode 100644 packages/sdk/shopify-oxygen/contract-tests/src/index.ts create mode 100644 packages/sdk/shopify-oxygen/contract-tests/src/utils/clientPool.ts create mode 100644 packages/sdk/shopify-oxygen/contract-tests/src/utils/index.ts create mode 100644 packages/sdk/shopify-oxygen/contract-tests/tsconfig.json create mode 100644 packages/sdk/shopify-oxygen/contract-tests/tsup.config.ts diff --git a/package.json b/package.json index fc665a8bd..12adfd2f6 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "packages/telemetry/browser-telemetry", "contract-tests", "packages/sdk/combined-browser", - "packages/sdk/shopify-oxygen" + "packages/sdk/shopify-oxygen", + "packages/sdk/shopify-oxygen/contract-tests" ], "private": true, "scripts": { diff --git a/packages/sdk/shopify-oxygen/contract-tests/.gitignore b/packages/sdk/shopify-oxygen/contract-tests/.gitignore new file mode 100644 index 000000000..bf0824e59 --- /dev/null +++ b/packages/sdk/shopify-oxygen/contract-tests/.gitignore @@ -0,0 +1 @@ +*.log \ No newline at end of file diff --git a/packages/sdk/shopify-oxygen/contract-tests/package.json b/packages/sdk/shopify-oxygen/contract-tests/package.json new file mode 100644 index 000000000..30d72f702 --- /dev/null +++ b/packages/sdk/shopify-oxygen/contract-tests/package.json @@ -0,0 +1,24 @@ +{ + "name": "@launchdarkly/shopify-oxygen-contract-tests", + "version": "0.0.0", + "main": "dist/index.js", + "scripts": { + "start": "node --inspect dist/index.js", + "build": "tsup", + "dev": "tsc --watch" + }, + "type": "module", + "license": "Apache-2.0", + "private": true, + "dependencies": { + "@launchdarkly/js-server-sdk-common": "workspace:^", + "@launchdarkly/shopify-oxygen-sdk": "workspace:^", + "express": "^5.0.1" + }, + "devDependencies": { + "@types/express": "^5.0.1", + "@types/node": "^18.11.9", + "tsup": "^8.5.0", + "typescript": "^4.9.0" + } +} diff --git a/packages/sdk/shopify-oxygen/contract-tests/run-test-harness.sh b/packages/sdk/shopify-oxygen/contract-tests/run-test-harness.sh new file mode 100755 index 000000000..d811782e7 --- /dev/null +++ b/packages/sdk/shopify-oxygen/contract-tests/run-test-harness.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# If sdk-test-harness is not in the path, then you will need to set +# the SDK_TEST_HARNESS environment variable to the path to the sdk-test-harness binary. + +# Default to the local sdk-test-harness binary if not provided +if [ -z "${SDK_TEST_HARNESS}" ]; then + SDK_TEST_HARNESS="sdk-test-harness" +fi + +# Uncomment this to start the test service in the background +# yarn start &> test-service.log & + +# skipping all tests that require streaming connections. +${SDK_TEST_HARNESS} --url http://localhost:8000 \ + --skip "streaming.*" \ + --skip "evaluation.*" \ + --skip "event.*" \ + --skip "service.*" \ + --stop-service-at-end diff --git a/packages/sdk/shopify-oxygen/contract-tests/src/index.ts b/packages/sdk/shopify-oxygen/contract-tests/src/index.ts new file mode 100644 index 000000000..64832fac8 --- /dev/null +++ b/packages/sdk/shopify-oxygen/contract-tests/src/index.ts @@ -0,0 +1,71 @@ +import express, { Request, Response } from 'express'; +import { Server } from 'http'; + +import { ClientPool } from './utils'; + +/* eslint-disable no-console */ + +// export DEBUG=true to enable debugging +// unset DEBUG to disable debugging +const debugging = process.env.DEBUG === 'true'; + +const app = express(); +let server: Server | null = null; + +app.use(express.json()); + +const port = 8000; + +const clientPool = new ClientPool(); + +if (debugging) { + app.use((req: Request, res: Response, next: Function) => { + console.debug('request', req.method, req.url); + if (req.body) { + console.debug('request', JSON.stringify(req.body, null, 2)); + } + next(); + }); +} else { + // NOOP global console.debug + console.debug = () => {}; +} + +app.get('/', (req: Request, res: Response) => { + res.header('Content-Type', 'application/json'); + res.json({ + capabilities: ['server-side-polling', 'server-side'], + }); +}); + +app.delete('/', (req: Request, res: Response) => { + console.log('Test service has told us to exit'); + res.status(204); + res.send(); + + if (server) { + server.close(() => process.exit()); + } +}); + +app.post('/', async (req: Request, res: Response) => { + await clientPool.createClient(req.body, res); + res.send(); +}); + +app.post('/clients/:id', async (req: Request, res: Response) => { + await clientPool.runCommand(req.params.id, req.body, res); + res.send(); +}); + +app.delete('/clients/:id', async (req: Request, res: Response) => { + console.debug('DELETE request received /clients/:id'); + console.debug(req.params.id); + await clientPool.deleteClient(req.params.id, res); + res.send(); +}); + +server = app.listen(port, () => { + // eslint-disable-next-line no-console + console.log('Listening on port %d', port); +}); diff --git a/packages/sdk/shopify-oxygen/contract-tests/src/utils/clientPool.ts b/packages/sdk/shopify-oxygen/contract-tests/src/utils/clientPool.ts new file mode 100644 index 000000000..e2045958d --- /dev/null +++ b/packages/sdk/shopify-oxygen/contract-tests/src/utils/clientPool.ts @@ -0,0 +1,109 @@ +import { Response } from 'express'; + +import { LDClient } from '@launchdarkly/js-server-sdk-common'; +import { init } from '@launchdarkly/shopify-oxygen-sdk'; + +/* eslint-disable no-console */ + +// NOTE: Currently, this is a very simple client pool that only really handles the +// very limited Oxygen specific use cases... we should be expand this to be more +// general purpose in the future and maybe even come up with some shared ts interface +// to facilitate future contract testing. + +/** + * ClientPool is a singleton that manages a pool of LDClient instances. Currently there is + * no separation between a managed client and this pool. Which means all of the client specs + * will be implemented in this class. + * + * @see https://github.com/launchdarkly/sdk-test-harness/blob/v2/docs/service_spec.md + */ +export default class ClientPool { + private _clients: Record = {}; + private _clientCounter = 0; + + constructor() { + this._clients = {}; + this._clientCounter = 0; + } + + private _makeId(): string { + this._clientCounter += 1; + return `client-${this._clientCounter}`; + } + + public async runCommand(id: string, body: any, res: Response): Promise { + const client = this._clients[id]; + // TODO: handle the 'itCanFailCase' + if (client) { + try { + const { command, ...rest } = body; + switch (command) { + case 'evaluate': { + const { flagKey, context, defaultValue, detail } = rest.evaluate; + const evaluation = detail + ? await client.variationDetail(flagKey, context, defaultValue) + : await client.variation(flagKey, context, defaultValue); + res.status(200); + res.json({ value: evaluation }); + break; + } + default: { + res.status(400); + res.json({ error: `Unknown command: ${command}` }); + break; + } + } + } catch (err) { + console.error(`Error running command: ${err}`); + res.status(500); + } + } else { + res.status(404); + } + } + + public async deleteClient(id: string, res: Response): Promise { + const client = this._clients[id]; + if (client) { + client.close(); + delete this._clients[id]; + res.status(204); + } else { + res.status(404); + } + } + + public async createClient(options: any, res: Response): Promise { + try { + const id = this._makeId(); + const { + configuration: { credential = 'unknown-sdk-key', polling }, + } = options; + + if (!polling) { + // We do not support non-polling clients yet + res.status(400); + return; + } + const client = await init(credential, { + ...(polling && { + baseUri: polling.baseUri, + }), + }); + + await client.waitForInitialization({ timeout: 10 }); + this._clients[id] = client; + res.status(201); + res.set('Location', `/clients/${id}`); + if (!client.initialized()) { + res.status(500); + client.close(); + } + this._clients[id] = client; + console.debug(`Creating client with configuration: ${JSON.stringify(options.configuration)}`); + } catch (err) { + console.error(`Error creating client: ${err}`); + res.status(500); + } + } +} diff --git a/packages/sdk/shopify-oxygen/contract-tests/src/utils/index.ts b/packages/sdk/shopify-oxygen/contract-tests/src/utils/index.ts new file mode 100644 index 000000000..42c87f696 --- /dev/null +++ b/packages/sdk/shopify-oxygen/contract-tests/src/utils/index.ts @@ -0,0 +1 @@ +export { default as ClientPool } from './clientPool'; diff --git a/packages/sdk/shopify-oxygen/contract-tests/tsconfig.json b/packages/sdk/shopify-oxygen/contract-tests/tsconfig.json new file mode 100644 index 000000000..b39d8dc4c --- /dev/null +++ b/packages/sdk/shopify-oxygen/contract-tests/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "lib": ["es6"], + "module": "ES6", + "moduleResolution": "node", + "noImplicitOverride": true, + "outDir": "dist", + "resolveJsonModule": true, + "rootDir": ".", + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "stripInternal": true, + "target": "ES2017", + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/sdk/shopify-oxygen/contract-tests/tsup.config.ts b/packages/sdk/shopify-oxygen/contract-tests/tsup.config.ts new file mode 100644 index 000000000..0758f21c4 --- /dev/null +++ b/packages/sdk/shopify-oxygen/contract-tests/tsup.config.ts @@ -0,0 +1,26 @@ +// It is a dev dependency and the linter doesn't understand. +// @ts-ignore - tsup is a dev dependency installed at runtime +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + }, + minify: true, + format: ['esm', 'cjs'], + splitting: false, + sourcemap: false, + clean: true, + dts: true, + metafile: true, + esbuildOptions(opts) { + // This would normally be `^_(?!meta|_)`, but go doesn't support negative look-ahead assertions, + // so we need to craft something that works without it. + // So start of line followed by a character that isn't followed by m or underscore, but we + // want other things that do start with m, so we need to progressively handle more characters + // of meta with exclusions. + // eslint-disable-next-line no-param-reassign + opts.mangleProps = /^_([^m|_]|m[^e]|me[^t]|met[^a])/; + }, +}); From 93d37b8e9369c1b098c6ff2334fa907479c477e1 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 13 Nov 2025 12:57:00 -0600 Subject: [PATCH 2/3] ci: adding Oxygen contract tests to CI --- .github/workflows/shopify-oxygen.yml | 12 ++++++++++++ .../sdk/shopify-oxygen/contract-tests/src/index.ts | 3 --- .../contract-tests/src/utils/clientPool.ts | 14 +++++++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/shopify-oxygen.yml b/.github/workflows/shopify-oxygen.yml index 3fa790903..cfc60cba8 100644 --- a/.github/workflows/shopify-oxygen.yml +++ b/.github/workflows/shopify-oxygen.yml @@ -26,3 +26,15 @@ jobs: with: workspace_name: '@launchdarkly/shopify-oxygen-sdk' workspace_path: packages/sdk/shopify-oxygen + - name: Install contract test service dependencies + run: yarn workspace @launchdarkly/shopify-oxygen-contract-tests install --no-immutable + - name: Build the test service + run: yarn workspace @launchdarkly/shopify-oxygen-contract-tests build + - name: Launch the test service in the background + run: yarn workspace @launchdarkly/shopify-oxygen-contract-tests start &> /dev/null & + - uses: launchdarkly/gh-actions/actions/contract-tests@61ea6c63de800b495a2fe40c0d4e4ba2a2833ee6 + with: + test_service_port: 8000 + token: ${{ secrets.GITHUB_TOKEN }} + # Based on run-test-harness.sh from the sdk package + extra_params: '--url http://localhost:8000 --skip "streaming.*" --skip "evaluation.*" --skip "event.*" --skip "service.*" --stop-service-at-end' diff --git a/packages/sdk/shopify-oxygen/contract-tests/src/index.ts b/packages/sdk/shopify-oxygen/contract-tests/src/index.ts index 64832fac8..4d3b02fa2 100644 --- a/packages/sdk/shopify-oxygen/contract-tests/src/index.ts +++ b/packages/sdk/shopify-oxygen/contract-tests/src/index.ts @@ -50,19 +50,16 @@ app.delete('/', (req: Request, res: Response) => { app.post('/', async (req: Request, res: Response) => { await clientPool.createClient(req.body, res); - res.send(); }); app.post('/clients/:id', async (req: Request, res: Response) => { await clientPool.runCommand(req.params.id, req.body, res); - res.send(); }); app.delete('/clients/:id', async (req: Request, res: Response) => { console.debug('DELETE request received /clients/:id'); console.debug(req.params.id); await clientPool.deleteClient(req.params.id, res); - res.send(); }); server = app.listen(port, () => { diff --git a/packages/sdk/shopify-oxygen/contract-tests/src/utils/clientPool.ts b/packages/sdk/shopify-oxygen/contract-tests/src/utils/clientPool.ts index e2045958d..f509631ae 100644 --- a/packages/sdk/shopify-oxygen/contract-tests/src/utils/clientPool.ts +++ b/packages/sdk/shopify-oxygen/contract-tests/src/utils/clientPool.ts @@ -10,6 +10,10 @@ import { init } from '@launchdarkly/shopify-oxygen-sdk'; // general purpose in the future and maybe even come up with some shared ts interface // to facilitate future contract testing. +// TODO: currently this class will handle the response sending as well, which may technically +// sit outside the scope of what it SHOULD be doing. We should refactor this to be more +// general purpose and allow the caller to handle the response sending. + /** * ClientPool is a singleton that manages a pool of LDClient instances. Currently there is * no separation between a managed client and this pool. Which means all of the client specs @@ -56,9 +60,11 @@ export default class ClientPool { } catch (err) { console.error(`Error running command: ${err}`); res.status(500); + res.send(); } } else { res.status(404); + res.send(); } } @@ -68,8 +74,10 @@ export default class ClientPool { client.close(); delete this._clients[id]; res.status(204); + res.send(); } else { res.status(404); + res.send(); } } @@ -83,6 +91,7 @@ export default class ClientPool { if (!polling) { // We do not support non-polling clients yet res.status(400); + res.send(); return; } const client = await init(credential, { @@ -98,12 +107,15 @@ export default class ClientPool { if (!client.initialized()) { res.status(500); client.close(); + res.send(); + return; } - this._clients[id] = client; console.debug(`Creating client with configuration: ${JSON.stringify(options.configuration)}`); + res.send(); } catch (err) { console.error(`Error creating client: ${err}`); res.status(500); + res.send(); } } } From df01447cbfc80b99982e14e3f69293963b409c3e Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 13 Nov 2025 16:48:30 -0600 Subject: [PATCH 3/3] chore: pr comment --- packages/sdk/shopify-oxygen/contract-tests/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/shopify-oxygen/contract-tests/.gitignore b/packages/sdk/shopify-oxygen/contract-tests/.gitignore index bf0824e59..397b4a762 100644 --- a/packages/sdk/shopify-oxygen/contract-tests/.gitignore +++ b/packages/sdk/shopify-oxygen/contract-tests/.gitignore @@ -1 +1 @@ -*.log \ No newline at end of file +*.log