Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { FileOverrideSourceOptions } from '@launchdarkly/js-server-sdk-common';

import LDClientNode from '../src/LDClientNode';

const user = { key: 'user-key' };

// The client never obtains data from LaunchDarkly: its only initializer reads a file that does
// not exist. Overrides are served from the override layer alone.
function makeClient(
directory: string,
overrides: Omit<FileOverrideSourceOptions, 'type'>,
): LDClientNode {
return new LDClientNode('sdk-key-file-overrides', {
sendEvents: false,
diagnosticOptOut: true,
logger: { error: () => {}, warn: () => {}, info: () => {}, debug: () => {} },
dataSystem: {
dataSource: {
dataSourceOptionsType: 'custom',
initializers: [{ type: 'file', paths: [join(directory, 'no-launchdarkly-data.json')] }],
synchronizers: [],
},
overrides: { type: 'file', ...overrides },
},
});
}

async function waitFor(condition: () => Promise<boolean>, timeoutMs: number = 10000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
if (await condition()) {
return;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
}
throw new Error('timed out waiting for the condition');
}

const document = (value: string) => JSON.stringify({ flagValues: { 'overridden-flag': value } });

describe('given a temporary directory of override files', () => {
let directory: string;
let client: LDClientNode | undefined;

beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'ld-file-overrides-'));
});

afterEach(async () => {
client?.close();
client = undefined;
await rm(directory, { recursive: true, force: true });
});

it('reads a YAML file with the built-in parser', async () => {
const path = join(directory, 'overrides.yaml');
await writeFile(path, 'flagValues:\n yaml-flag: "override-value"\n');
client = makeClient(directory, { paths: [path] });

const detail = await client.variationDetail('yaml-flag', user, 'default');

expect(detail.value).toEqual('override-value');
expect(detail.reason).toEqual({ kind: 'FALLTHROUGH', overrideAffected: true });
expect(client.initialized()).toBe(false);
});

it('reloads a changed file in polling mode', async () => {
const path = join(directory, 'overrides.json');
await writeFile(path, document('b'));
client = makeClient(directory, { paths: [path], changeDetection: 'polling', pollInterval: 1 });
expect(await client.variation('overridden-flag', user, 'default')).toEqual('b');

await writeFile(path, document('c'));

await waitFor(
async () => (await client!.variation('overridden-flag', user, 'default')) === 'c',
);
});

it('picks up a file that does not exist yet in watching mode', async () => {
const path = join(directory, 'overrides.json');
client = makeClient(directory, { paths: [path], changeDetection: 'watching' });
const before = await client.variationDetail('overridden-flag', user, 'default');
expect(before.reason).toEqual({ kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' });

await writeFile(path, document('b'));

await waitFor(
async () => (await client!.variation('overridden-flag', user, 'default')) === 'b',
);
});

it('removes the overrides of a deleted file', async () => {
const path = join(directory, 'overrides.json');
await writeFile(path, document('b'));
client = makeClient(directory, { paths: [path] });
expect(await client.variation('overridden-flag', user, 'default')).toEqual('b');

await unlink(path);

await waitFor(async () => {
const detail = await client!.variationDetail('overridden-flag', user, 'default');
return detail.reason.errorKind === 'CLIENT_NOT_READY';
});
});

it('keeps the last good overrides while the file is malformed', async () => {
const path = join(directory, 'overrides.json');
await writeFile(path, document('b'));
client = makeClient(directory, { paths: [path] });
expect(await client.variation('overridden-flag', user, 'default')).toEqual('b');

await writeFile(path, '{"flagValues"');
await new Promise((resolve) => {
setTimeout(resolve, 1500);
});
expect(await client.variation('overridden-flag', user, 'default')).toEqual('b');

await writeFile(path, document('c'));

await waitFor(
async () => (await client!.variation('overridden-flag', user, 'default')) === 'c',
);
});
});
3 changes: 2 additions & 1 deletion packages/sdk/server-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"dependencies": {
"@launchdarkly/js-server-sdk-common": "2.21.5",
"https-proxy-agent": "^7.0.6",
"launchdarkly-eventsource": "2.2.0"
"launchdarkly-eventsource": "2.2.0",
"yaml": "2.9.1"
},
"devDependencies": {
"@types/jest": "^29.4.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/server-node/src/LDClientNode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EventEmitter } from 'events';
import { format } from 'util';
import { parse as parseYaml } from 'yaml';

import {
BasicLogger,
Expand Down Expand Up @@ -89,6 +90,8 @@ class LDClientNode extends LDClientImpl implements LDClient {
getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) =>
internal.safeGetHooks(logger, environmentMetadata, plugins),
instanceId,
// The Node platform reads YAML files without configuration from the application.
yamlParser: (data: string) => parseYaml(data),
},
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { LDLogger } from '@launchdarkly/js-sdk-common';

import { FileOverrideSourceOptions } from '../src/api/options/LDDataSystemOptions';
import { LDOptions } from '../src/api/options/LDOptions';
import LDClientImpl from '../src/LDClientImpl';
import MockFilesystem from './data_sources/filedata/MockFilesystem';
import { makeCallbacks, makeFDv2Platform } from './overrides/overridesTestSupport';

const user = { key: 'user-key' };
const directory = '/etc/launchdarkly';
const jsonPath = `${directory}/overrides.json`;
const yamlPath = `${directory}/overrides.yaml`;

function makeLogger(): LDLogger {
return { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
}

async function waitFor(condition: () => Promise<boolean>, timeoutMs: number = 3000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
if (await condition()) {
return;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
}
throw new Error('timed out waiting for the condition');
}

describe('given a client with a file override source over a mock filesystem', () => {
let filesystem: MockFilesystem;
let client: LDClientImpl | undefined;
let platformYamlParser: jest.Mock;

const makeClient = (
overrides: Omit<FileOverrideSourceOptions, 'type'>,
options: LDOptions = {},
) => {
client = new LDClientImpl(
'sdk-key-file-overrides',
{ ...makeFDv2Platform(), fileSystem: filesystem },
{
sendEvents: false,
diagnosticOptOut: true,
logger: makeLogger(),
...options,
dataSystem: {
dataSource: {
dataSourceOptionsType: 'custom',
initializers: [],
synchronizers: [{ type: 'polling', pollInterval: 1000 }],
},
overrides: { type: 'file', changeDetection: 'watching', ...overrides },
},
},
makeCallbacks(),
{ yamlParser: platformYamlParser },
);
return client;
};

beforeEach(() => {
filesystem = new MockFilesystem();
platformYamlParser = jest.fn(() => ({ flagValues: { 'yaml-flag': 'from-platform-parser' } }));
});

afterEach(() => {
client?.close();
client = undefined;
});

it('serves an override from a file that is present when the client is created', async () => {
filesystem.set(jsonPath, '{"flagValues": {"overridden-flag": true}}');
makeClient({ paths: [jsonPath] });

const detail = await client!.boolVariationDetail('overridden-flag', user, false);

expect(detail.value).toBe(true);
expect(detail.reason).toEqual({ kind: 'FALLTHROUGH', overrideAffected: true });
expect(client!.initialized()).toBe(false);
});

it('reads a YAML file with the parser the platform supplies', async () => {
filesystem.set(yamlPath, 'flagValues:\n yaml-flag: from-platform-parser\n');
makeClient({ paths: [yamlPath] });

const value = await client!.variation('yaml-flag', user, 'default');

expect(value).toEqual('from-platform-parser');
expect(platformYamlParser).toHaveBeenCalledWith(
'flagValues:\n yaml-flag: from-platform-parser\n',
);
});

it('prefers a configured YAML parser over the one the platform supplies', async () => {
const yamlParser = jest.fn(() => ({ flagValues: { 'yaml-flag': 'from-configured-parser' } }));
filesystem.set(yamlPath, 'flagValues:\n yaml-flag: x\n');
makeClient({ paths: [yamlPath], yamlParser });

expect(await client!.variation('yaml-flag', user, 'default')).toEqual('from-configured-parser');
expect(platformYamlParser).not.toHaveBeenCalled();
});

it('reloads when the directory reports a change', async () => {
filesystem.set(jsonPath, '{"flagValues": {"overridden-flag": "b"}}');
makeClient({ paths: [jsonPath] });
expect(await client!.variation('overridden-flag', user, 'default')).toEqual('b');

filesystem.set(jsonPath, '{"flagValues": {"overridden-flag": "c"}}');
filesystem.emit(directory);

await waitFor(
async () => (await client!.variation('overridden-flag', user, 'default')) === 'c',
);
});

it('contributes no overrides for a file that does not exist until it appears', async () => {
makeClient({ paths: [jsonPath] });

const before = await client!.variationDetail('overridden-flag', user, 'default');
expect(before.reason).toEqual({ kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' });

filesystem.set(jsonPath, '{"flagValues": {"overridden-flag": "b"}}');
filesystem.emit(directory, 'rename');

await waitFor(
async () => (await client!.variation('overridden-flag', user, 'default')) === 'b',
);
});

it('fails client construction when no file paths are configured', () => {
expect(() => makeClient({ paths: [] })).toThrow(
'The file-based override source requires at least one file path',
);
});
});
7 changes: 7 additions & 0 deletions packages/shared/sdk-server/__tests__/Logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ export default class TestLogger implements LDLogger {
});
}

/**
* The messages received at a level, in order.
*/
getMessages(level: LogLevel): string[] {
return [...this._messages[level]];
}

getCount(level?: LogLevel) {
if (level === undefined) {
return this._callCount;
Expand Down
Loading
Loading