Files
SnowLuma/packages/sdk/tests/package.test.ts
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

262 lines
9.4 KiB
TypeScript

import { spawnSync } from 'node:child_process';
import {
copyFileSync,
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, beforeAll, describe, it } from 'vitest';
const sdkRoot = fileURLToPath(new URL('..', import.meta.url));
const compiler = createRequire(import.meta.url).resolve('typescript/bin/tsc');
const manifest = JSON.parse(readFileSync(path.join(sdkRoot, 'package.json'), 'utf8')) as {
name: string;
exports: Record<string, string | { import: string; types: string }>;
};
const entrypoints = Object.entries(manifest.exports)
.filter(([, target]) => typeof target !== 'string')
.map(([entry]) => `${manifest.name}${entry === '.' ? '' : entry.slice(1)}`);
let consumerRoot: string;
function node(args: string[]): void {
const result = spawnSync(process.execPath, args, {
cwd: consumerRoot,
encoding: 'utf8',
stdio: 'pipe',
timeout: 30_000,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`Node exited with status ${result.status} (signal ${result.signal}):\n${result.stdout}\n${result.stderr}`,
);
}
}
beforeAll(() => {
consumerRoot = mkdtempSync(path.join(tmpdir(), 'snowluma-sdk-package-'));
const installed = path.join(consumerRoot, 'node_modules', '@snowluma', 'sdk');
mkdirSync(installed, { recursive: true });
copyFileSync(path.join(sdkRoot, 'package.json'), path.join(installed, 'package.json'));
// Install the declared runtime dependency into this isolated consumer. Node
// type declarations belong to the consuming Node project's dev environment.
const require = createRequire(import.meta.url);
cpSync(
path.dirname(require.resolve('ws/package.json')),
path.join(consumerRoot, 'node_modules', 'ws'),
{ recursive: true },
);
mkdirSync(path.join(consumerRoot, 'node_modules', '@types'), { recursive: true });
symlinkSync(
path.dirname(require.resolve('@types/node/package.json')),
path.join(consumerRoot, 'node_modules', '@types', 'node'),
'dir',
);
copyFileSync(
path.join(sdkRoot, 'tests', 'fixtures', 'action-types.mts'),
path.join(consumerRoot, 'action-types.mts'),
);
copyFileSync(
path.join(sdkRoot, 'tests', 'fixtures', 'action-contracts.ts'),
path.join(consumerRoot, 'action-contracts.ts'),
);
copyFileSync(
path.join(sdkRoot, 'tests', 'fixtures', 'messaging-types.mts'),
path.join(consumerRoot, 'messaging-types.mts'),
);
writeFileSync(
path.join(consumerRoot, 'reverse-types.mts'),
`import { text } from '@snowluma/sdk';
import { createReverseWebSocketServer, type SnowLumaReverseWebSocketSession } from '@snowluma/sdk/node';
const reverse = createReverseWebSocketServer({ port: 0, selfIds: ['10001'] });
reverse.on('connection', (bot: SnowLumaReverseWebSocketSession) => {
const id: string = bot.selfId;
const ready: boolean = bot.canCallActions;
void bot.getStatus();
bot.onMessage(async (_event, context) => { await context.reply(text(id)); });
void ready;
});
`,
);
writeFileSync(
path.join(consumerRoot, 'browser-types.mts'),
`
import { SnowLumaWebSocketClient, text } from '@snowluma/sdk';
new SnowLumaWebSocketClient().onMessage(async (_event, context) => { await context.reply(text('hello')); });
// @ts-expect-error Node globals must not leak through the browser entry.
process.cwd();
`,
);
writeFileSync(
path.join(consumerRoot, 'tsconfig.browser.json'),
JSON.stringify({
compilerOptions: {
strict: true,
noEmit: true,
target: 'ES2022',
lib: ['ES2022', 'DOM'],
module: 'ESNext',
moduleResolution: 'Bundler',
types: [],
},
files: ['browser-types.mts'],
}),
);
// Compile fresh output outside the workspace so neither stale dist files nor
// Vitest's source resolver can hide a broken published entry point.
node([
compiler,
'--project',
path.join(sdkRoot, 'tsconfig.json'),
'--outDir',
path.join(installed, 'dist'),
]);
writeFileSync(
path.join(consumerRoot, 'consumer.mts'),
`import { SnowLumaHttpClient, text, type SnowLumaKnownAction as RootKnownAction } from '@snowluma/sdk';
import { SnowLumaWebSocketClient, type SnowLumaHttpClientOptions } from '@snowluma/sdk/client';
import { MessageChain } from '@snowluma/sdk/messages';
import { isMessageEvent, type EventMiddleware } from '@snowluma/sdk/events';
import { SnowLumaApiError } from '@snowluma/sdk/errors';
import { SNOWLUMA_ACTIONS, isSnowLumaAction, type SnowLumaKnownAction } from '@snowluma/sdk/actions';
import type { ActionParams, StatusInfo } from '@snowluma/sdk/types';
const options: SnowLumaHttpClientOptions = { baseUrl: 'http://127.0.0.1:3000/' };
const client = new SnowLumaHttpClient(options);
const status: Promise<StatusInfo> = client.getStatus();
const message: MessageChain = text('hello').reply(-42);
const params: ActionParams<'send_group_msg'> = { group_id: 123, message: [] };
client.raw('send_group_msg', params);
client.raw('consumer_extension', { custom: true });
client.sendGroupMessage(123, message);
const middleware: EventMiddleware = async (event, context, next) => {
if (isMessageEvent(event)) await context.reply(text('received'));
await next();
};
new SnowLumaWebSocketClient().use(middleware);
const action: RootKnownAction = SNOWLUMA_ACTIONS[0];
function known(value: string): SnowLumaKnownAction | null {
return isSnowLumaAction(value) ? value : null;
}
const error = new SnowLumaApiError({ status: 'failed', retcode: 1, data: null });
export { status, action, known, error };
`,
);
}, 30_000);
afterAll(() => {
if (consumerRoot) {
rmSync(consumerRoot, { recursive: true, force: true });
}
});
describe('SDK package entry points', () => {
it('keeps browser consumers independent of Node server declarations', () => {
node([compiler, '-p', path.join(consumerRoot, 'tsconfig.browser.json')]);
}, 30_000);
it('starts the published Node reverse server without workspace resolution', () => {
node([
'--input-type=module',
'--eval',
`
import assert from 'node:assert/strict';
import { createReverseWebSocketServer } from '@snowluma/sdk/node';
const server = createReverseWebSocketServer({ port: 0 });
await server.listen();
assert.ok(server.address.port > 0);
await server.close();
`,
]);
});
it.each(entrypoints)('loads %s in native Node without a bundler', (entrypoint) => {
node(['--input-type=module', '--eval', `await import(${JSON.stringify(entrypoint)});`]);
});
it('preserves runtime identities across the root and subpath exports', () => {
node([
'--input-type=module',
'--eval',
`import assert from 'node:assert/strict';
import * as sdk from '@snowluma/sdk';
import * as client from '@snowluma/sdk/client';
import * as messages from '@snowluma/sdk/messages';
import * as events from '@snowluma/sdk/events';
import * as errors from '@snowluma/sdk/errors';
import * as actions from '@snowluma/sdk/actions';
assert.equal(sdk.SnowLumaHttpClient, client.SnowLumaHttpClient);
assert.equal(sdk.SnowLumaWebSocketClient, client.SnowLumaWebSocketClient);
assert.equal(sdk.MessageChain, messages.MessageChain);
assert.equal(sdk.quoteMessage, messages.quoteMessage);
assert.equal(sdk.isMessageEvent, events.isMessageEvent);
assert.equal(sdk.SnowLumaApiError, errors.SnowLumaApiError);
assert.equal(sdk.SnowLumaMediaSendError, errors.SnowLumaMediaSendError);
assert.equal(sdk.SNOWLUMA_ACTIONS, actions.SNOWLUMA_ACTIONS);
assert.equal(sdk.isSnowLumaAction, actions.isSnowLumaAction);
assert.ok(sdk.text('hello') instanceof messages.MessageChain);
assert.equal(actions.isSnowLumaAction('send_group_msg'), true);
assert.equal(actions.isSnowLumaAction('consumer_extension'), false);
`,
]);
});
it.each([
['NodeNext', 'NodeNext'],
['ESNext', 'Bundler'],
])(
'typechecks a consumer using %s / %s',
(module, resolution) => {
node([
compiler,
'--noEmit',
'--strict',
'--target',
'ES2022',
'--lib',
'ES2022,DOM',
'--module',
module,
'--moduleResolution',
resolution,
path.join(consumerRoot, 'consumer.mts'),
path.join(consumerRoot, 'action-types.mts'),
path.join(consumerRoot, 'messaging-types.mts'),
]);
node([
compiler,
'--noEmit',
'--strict',
'--target',
'ES2022',
'--lib',
'ES2022,DOM',
'--types',
'node',
'--module',
module,
'--moduleResolution',
resolution,
path.join(consumerRoot, 'reverse-types.mts'),
]);
},
30_000,
);
});