Skip to content

Commit 40b11ef

Browse files
authored
feat(tests): add TypeScript client integration test support (#4185)
Integration tests can now validate the TypeScript SDK alongside Python tests when running against server-mode stacks. Currently, this only adds a _small_ number of tests. We should extend only if truly needed -- this smoke check may be sufficient. When `RUN_CLIENT_TS_TESTS=1` is set, the test script runs TypeScript tests after Python tests pass. Tests are mapped via `tests/integration/client-typescript/suites.json` which defines which TypeScript test files correspond to each Python suite/setup combination. The fact that we need exact "test_id"s (which are actually generated by pytest) to be hardcoded inside the Typescript tests (so we hit the recorded paths) is a big smell and it might become grating, but maybe the benefit is worth it if we keep this test suite _small_ and targeted. ## Test Plan Run with TypeScript tests enabled: ```bash OPENAI_API_KEY=dummy RUN_CLIENT_TS_TESTS=1 \ scripts/integration-tests.sh --stack-config server:ci-tests --suite responses --setup gpt ```
1 parent 4e9633f commit 40b11ef

File tree

15 files changed

+6208
-10
lines changed

15 files changed

+6208
-10
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Setup TypeScript client
2+
description: Conditionally checkout and link llama-stack-client-typescript based on client-version
3+
inputs:
4+
client-version:
5+
description: 'Client version (latest or published)'
6+
required: true
7+
8+
outputs:
9+
ts-client-path:
10+
description: 'Path or version to use for TypeScript client'
11+
value: ${{ steps.set-path.outputs.ts-client-path }}
12+
13+
runs:
14+
using: "composite"
15+
steps:
16+
- name: Checkout TypeScript client (latest)
17+
if: ${{ inputs.client-version == 'latest' }}
18+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
19+
with:
20+
repository: llamastack/llama-stack-client-typescript
21+
ref: main
22+
path: .ts-client-checkout
23+
24+
- name: Set TS_CLIENT_PATH
25+
id: set-path
26+
shell: bash
27+
run: |
28+
if [ "${{ inputs.client-version }}" = "latest" ]; then
29+
echo "ts-client-path=${{ github.workspace }}/.ts-client-checkout" >> $GITHUB_OUTPUT
30+
elif [ "${{ inputs.client-version }}" = "published" ]; then
31+
echo "ts-client-path=^0.3.2" >> $GITHUB_OUTPUT
32+
else
33+
echo "::error::Invalid client-version: ${{ inputs.client-version }}"
34+
exit 1
35+
fi

.github/workflows/integration-tests.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,27 @@ jobs:
9393
suite: ${{ matrix.config.suite }}
9494
inference-mode: 'replay'
9595

96+
- name: Setup Node.js for TypeScript client tests
97+
if: ${{ matrix.client == 'server' }}
98+
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
99+
with:
100+
node-version: '20'
101+
cache: 'npm'
102+
cache-dependency-path: tests/integration/client-typescript/package-lock.json
103+
104+
- name: Setup TypeScript client
105+
if: ${{ matrix.client == 'server' }}
106+
id: setup-ts-client
107+
uses: ./.github/actions/setup-typescript-client
108+
with:
109+
client-version: ${{ matrix.client-version }}
110+
96111
- name: Run tests
97112
if: ${{ matrix.config.allowed_clients == null || contains(matrix.config.allowed_clients, matrix.client) }}
98113
uses: ./.github/actions/run-and-record-tests
99114
env:
100115
OPENAI_API_KEY: dummy
116+
TS_CLIENT_PATH: ${{ steps.setup-ts-client.outputs.ts-client-path || '' }}
101117
with:
102118
stack-config: >-
103119
${{ matrix.config.stack_config

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@ docs/static/imported-files/
3535
docs/docs/api-deprecated/
3636
docs/docs/api-experimental/
3737
docs/docs/api/
38+
tests/integration/client-typescript/node_modules/
39+
.ts-client-checkout/

scripts/get_setup_env.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@
1616
from tests.integration.suites import SETUP_DEFINITIONS, SUITE_DEFINITIONS
1717

1818

19-
def get_setup_env_vars(setup_name, suite_name=None):
19+
def get_setup_config(setup_name, suite_name=None):
2020
"""
21-
Get environment variables for a setup, with optional suite default fallback.
21+
Get full configuration (env vars + defaults) for a setup.
2222
2323
Args:
2424
setup_name: Name of the setup (e.g., 'ollama', 'gpt')
2525
suite_name: Optional suite name to get default setup if setup_name is None
2626
2727
Returns:
28-
Dictionary of environment variables
28+
Dictionary with 'env' and 'defaults' keys
2929
"""
3030
# If no setup specified, try to get default from suite
3131
if not setup_name and suite_name:
@@ -34,7 +34,7 @@ def get_setup_env_vars(setup_name, suite_name=None):
3434
setup_name = suite.default_setup
3535

3636
if not setup_name:
37-
return {}
37+
return {"env": {}, "defaults": {}}
3838

3939
setup = SETUP_DEFINITIONS.get(setup_name)
4040
if not setup:
@@ -44,27 +44,31 @@ def get_setup_env_vars(setup_name, suite_name=None):
4444
)
4545
sys.exit(1)
4646

47-
return setup.env
47+
return {"env": setup.env, "defaults": setup.defaults}
4848

4949

5050
def main():
51-
parser = argparse.ArgumentParser(description="Extract environment variables from a test setup")
51+
parser = argparse.ArgumentParser(description="Extract environment variables and defaults from a test setup")
5252
parser.add_argument("--setup", help="Setup name (e.g., ollama, gpt)")
5353
parser.add_argument("--suite", help="Suite name to get default setup from if --setup not provided")
5454
parser.add_argument("--format", choices=["bash", "json"], default="bash", help="Output format (default: bash)")
5555

5656
args = parser.parse_args()
5757

58-
env_vars = get_setup_env_vars(args.setup, args.suite)
58+
config = get_setup_config(args.setup, args.suite)
5959

6060
if args.format == "bash":
61-
# Output as bash export statements
62-
for key, value in env_vars.items():
61+
# Output env vars as bash export statements
62+
for key, value in config["env"].items():
6363
print(f"export {key}='{value}'")
64+
# Output defaults as bash export statements with LLAMA_STACK_TEST_ prefix
65+
for key, value in config["defaults"].items():
66+
env_key = f"LLAMA_STACK_TEST_{key.upper()}"
67+
print(f"export {env_key}='{value}'")
6468
elif args.format == "json":
6569
import json
6670

67-
print(json.dumps(env_vars))
71+
print(json.dumps(config))
6872

6973

7074
if __name__ == "__main__":

scripts/integration-tests.sh

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ echo "$SETUP_ENV"
181181
eval "$SETUP_ENV"
182182
echo ""
183183

184+
# Export suite and setup names for TypeScript tests
185+
export LLAMA_STACK_TEST_SUITE="$TEST_SUITE"
186+
export LLAMA_STACK_TEST_SETUP="$TEST_SETUP"
187+
184188
ROOT_DIR="$THIS_DIR/.."
185189
cd $ROOT_DIR
186190

@@ -212,6 +216,71 @@ find_available_port() {
212216
return 1
213217
}
214218

219+
run_client_ts_tests() {
220+
if ! command -v npm &>/dev/null; then
221+
echo "npm could not be found; ensure Node.js is installed"
222+
return 1
223+
fi
224+
225+
pushd tests/integration/client-typescript >/dev/null
226+
227+
# Determine if TS_CLIENT_PATH is a directory path or an npm version
228+
if [[ -d "$TS_CLIENT_PATH" ]]; then
229+
# It's a directory path - use local checkout
230+
if [[ ! -f "$TS_CLIENT_PATH/package.json" ]]; then
231+
echo "Error: $TS_CLIENT_PATH exists but doesn't look like llama-stack-client-typescript (no package.json)"
232+
popd >/dev/null
233+
return 1
234+
fi
235+
echo "Using local llama-stack-client-typescript from: $TS_CLIENT_PATH"
236+
237+
# Build the TypeScript client first
238+
echo "Building TypeScript client..."
239+
pushd "$TS_CLIENT_PATH" >/dev/null
240+
npm install --silent
241+
npm run build --silent
242+
popd >/dev/null
243+
244+
# Install other dependencies first
245+
if [[ "${CI:-}" == "true" || "${CI:-}" == "1" ]]; then
246+
npm ci --silent
247+
else
248+
npm install --silent
249+
fi
250+
251+
# Then install the client from local directory
252+
echo "Installing llama-stack-client from: $TS_CLIENT_PATH"
253+
npm install "$TS_CLIENT_PATH" --silent
254+
else
255+
# It's an npm version specifier - install from npm
256+
echo "Installing llama-stack-client@${TS_CLIENT_PATH} from npm"
257+
if [[ "${CI:-}" == "true" || "${CI:-}" == "1" ]]; then
258+
npm ci --silent
259+
npm install "llama-stack-client@${TS_CLIENT_PATH}" --silent
260+
else
261+
npm install "llama-stack-client@${TS_CLIENT_PATH}" --silent
262+
fi
263+
fi
264+
265+
# Verify installation
266+
echo "Verifying llama-stack-client installation..."
267+
if npm list llama-stack-client 2>/dev/null | grep -q llama-stack-client; then
268+
echo "✅ llama-stack-client successfully installed"
269+
npm list llama-stack-client
270+
else
271+
echo "❌ llama-stack-client not found in node_modules"
272+
echo "Installed packages:"
273+
npm list --depth=0
274+
popd >/dev/null
275+
return 1
276+
fi
277+
278+
echo "Running TypeScript tests for suite $TEST_SUITE (setup $TEST_SETUP)"
279+
npm test
280+
281+
popd >/dev/null
282+
}
283+
215284
# Start Llama Stack Server if needed
216285
if [[ "$STACK_CONFIG" == *"server:"* && "$COLLECT_ONLY" == false ]]; then
217286
# Find an available port for the server
@@ -221,6 +290,7 @@ if [[ "$STACK_CONFIG" == *"server:"* && "$COLLECT_ONLY" == false ]]; then
221290
exit 1
222291
fi
223292
export LLAMA_STACK_PORT
293+
export TEST_API_BASE_URL="http://localhost:$LLAMA_STACK_PORT"
224294
echo "Will use port: $LLAMA_STACK_PORT"
225295

226296
stop_server() {
@@ -298,6 +368,7 @@ if [[ "$STACK_CONFIG" == *"docker:"* && "$COLLECT_ONLY" == false ]]; then
298368
exit 1
299369
fi
300370
export LLAMA_STACK_PORT
371+
export TEST_API_BASE_URL="http://localhost:$LLAMA_STACK_PORT"
301372
echo "Will use port: $LLAMA_STACK_PORT"
302373

303374
echo "=== Building Docker Image for distribution: $DISTRO ==="
@@ -506,5 +577,10 @@ else
506577
exit 1
507578
fi
508579

580+
# Run TypeScript client tests if TS_CLIENT_PATH is set
581+
if [[ $exit_code -eq 0 && -n "${TS_CLIENT_PATH:-}" && "${LLAMA_STACK_TEST_STACK_CONFIG_TYPE:-}" == "server" ]]; then
582+
run_client_ts_tests
583+
fi
584+
509585
echo ""
510586
echo "=== Integration Tests Complete ==="

tests/integration/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,23 @@ def test_asymmetric_embeddings(llama_stack_client, embedding_model_id):
211211

212212
assert query_response.embeddings is not None
213213
```
214+
215+
## TypeScript Client Replays
216+
217+
TypeScript SDK tests can run alongside Python tests when testing against `server:<config>` stacks. Set `TS_CLIENT_PATH` to the path or version of `llama-stack-client-typescript` to enable:
218+
219+
```bash
220+
# Use published npm package (responses suite)
221+
TS_CLIENT_PATH=^0.3.2 scripts/integration-tests.sh --stack-config server:ci-tests --suite responses --setup gpt
222+
223+
# Use local checkout from ~/.cache (recommended for development)
224+
git clone https://github.com/llamastack/llama-stack-client-typescript.git ~/.cache/llama-stack-client-typescript
225+
TS_CLIENT_PATH=~/.cache/llama-stack-client-typescript scripts/integration-tests.sh --stack-config server:ci-tests --suite responses --setup gpt
226+
227+
# Run base suite with TypeScript tests
228+
TS_CLIENT_PATH=~/.cache/llama-stack-client-typescript scripts/integration-tests.sh --stack-config server:ci-tests --suite base --setup ollama
229+
```
230+
231+
TypeScript tests run immediately after Python tests pass, using the same replay fixtures. The mapping between Python suites/setups and TypeScript test files is defined in `tests/integration/client-typescript/suites.json`.
232+
233+
If `TS_CLIENT_PATH` is unset, TypeScript tests are skipped entirely.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) Meta Platforms, Inc. and affiliates.
2+
// All rights reserved.
3+
//
4+
// This source code is licensed under the terms described in the LICENSE file in
5+
// the root directory of this source tree.
6+
7+
/**
8+
* Integration tests for Inference API (Chat Completions).
9+
* Ported from: llama-stack/tests/integration/inference/test_openai_completion.py
10+
*
11+
* IMPORTANT: Test cases must match EXACTLY with Python tests to use recorded API responses.
12+
*/
13+
14+
import { createTestClient, requireTextModel } from '../setup';
15+
16+
describe('Inference API - Chat Completions', () => {
17+
// Test cases matching llama-stack/tests/integration/test_cases/inference/chat_completion.json
18+
const chatCompletionTestCases = [
19+
{
20+
id: 'non_streaming_01',
21+
question: 'Which planet do humans live on?',
22+
expected: 'earth',
23+
testId:
24+
'tests/integration/inference/test_openai_completion.py::test_openai_chat_completion_non_streaming[client_with_models-txt=ollama/llama3.2:3b-instruct-fp16-inference:chat_completion:non_streaming_01]',
25+
},
26+
{
27+
id: 'non_streaming_02',
28+
question: 'Which planet has rings around it with a name starting with letter S?',
29+
expected: 'saturn',
30+
testId:
31+
'tests/integration/inference/test_openai_completion.py::test_openai_chat_completion_non_streaming[client_with_models-txt=ollama/llama3.2:3b-instruct-fp16-inference:chat_completion:non_streaming_02]',
32+
},
33+
];
34+
35+
const streamingTestCases = [
36+
{
37+
id: 'streaming_01',
38+
question: "What's the name of the Sun in latin?",
39+
expected: 'sol',
40+
testId:
41+
'tests/integration/inference/test_openai_completion.py::test_openai_chat_completion_streaming[client_with_models-txt=ollama/llama3.2:3b-instruct-fp16-inference:chat_completion:streaming_01]',
42+
},
43+
{
44+
id: 'streaming_02',
45+
question: 'What is the name of the US captial?',
46+
expected: 'washington',
47+
testId:
48+
'tests/integration/inference/test_openai_completion.py::test_openai_chat_completion_streaming[client_with_models-txt=ollama/llama3.2:3b-instruct-fp16-inference:chat_completion:streaming_02]',
49+
},
50+
];
51+
52+
test.each(chatCompletionTestCases)(
53+
'chat completion non-streaming: $id',
54+
async ({ question, expected, testId }) => {
55+
const client = createTestClient(testId);
56+
const textModel = requireTextModel();
57+
58+
const response = await client.chat.completions.create({
59+
model: textModel,
60+
messages: [
61+
{
62+
role: 'user',
63+
content: question,
64+
},
65+
],
66+
stream: false,
67+
});
68+
69+
// Non-streaming responses have choices with message property
70+
const choice = response.choices[0];
71+
expect(choice).toBeDefined();
72+
if (!choice || !('message' in choice)) {
73+
throw new Error('Expected non-streaming response with message');
74+
}
75+
const content = choice.message.content;
76+
expect(content).toBeDefined();
77+
const messageContent = typeof content === 'string' ? content.toLowerCase().trim() : '';
78+
expect(messageContent.length).toBeGreaterThan(0);
79+
expect(messageContent).toContain(expected.toLowerCase());
80+
},
81+
);
82+
83+
test.each(streamingTestCases)('chat completion streaming: $id', async ({ question, expected, testId }) => {
84+
const client = createTestClient(testId);
85+
const textModel = requireTextModel();
86+
87+
const stream = await client.chat.completions.create({
88+
model: textModel,
89+
messages: [{ role: 'user', content: question }],
90+
stream: true,
91+
});
92+
93+
const streamedContent: string[] = [];
94+
for await (const chunk of stream) {
95+
if (chunk.choices && chunk.choices.length > 0 && chunk.choices[0]?.delta?.content) {
96+
streamedContent.push(chunk.choices[0].delta.content);
97+
}
98+
}
99+
100+
expect(streamedContent.length).toBeGreaterThan(0);
101+
const fullContent = streamedContent.join('').toLowerCase().trim();
102+
expect(fullContent).toContain(expected.toLowerCase());
103+
});
104+
});

0 commit comments

Comments
 (0)