Skip to content
Merged
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,101 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

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

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

// Kubernetes updates a mounted ConfigMap or Secret with an atomic symbolic link swap: the data
// lives in a timestamped directory, `..data` links to it, and each file in the mount is a link
// into `..data`. An update writes a new timestamped directory, links `..data_tmp` to it, and
// renames `..data_tmp` over `..data`. The configured file name never appears in an event.
// Symbolic links need privileges on Windows, so the test runs elsewhere.

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

const settle = (ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});

async function waitFor(condition: () => Promise<boolean>, timeoutMs: number = 5000) {
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 settle(20);
}
throw new Error('timed out waiting for the condition');
}

const itOnPosix = process.platform === 'win32' ? it.skip : it;

describe('given a file data source with automatic updates over a ConfigMap-style mount', () => {
let base: string;
let mount: string;
let client: LDClientNode | undefined;
let version = 0;

const writeVersion = (value: string) => {
version += 1;
const name = `..2026_${version}`;
fs.mkdirSync(path.join(mount, name));
fs.writeFileSync(path.join(mount, name, 'flags.json'), document(value));
return name;
};

const swapTo = (name: string) => {
const previous = fs.readlinkSync(path.join(mount, '..data'));
fs.symlinkSync(name, path.join(mount, '..data_tmp'));
fs.renameSync(path.join(mount, '..data_tmp'), path.join(mount, '..data'));
fs.rmSync(path.join(mount, previous), { recursive: true });
};

beforeEach(() => {
base = fs.mkdtempSync(path.join(os.tmpdir(), 'file-data-source-configmap-'));
mount = path.join(base, 'config');
fs.mkdirSync(mount);
const first = writeVersion('a');
fs.symlinkSync(first, path.join(mount, '..data'));
fs.symlinkSync(path.join('..data', 'flags.json'), path.join(mount, 'flags.json'));
});

afterEach(() => {
client?.close();
client = undefined;
fs.rmSync(base, { recursive: true, force: true });
});

const start = async () => {
const source = new integrations.FileDataSourceFactory({
paths: [path.join(mount, 'flags.json')],
autoUpdate: true,
});
client = new LDClientNode('sdk-key', {
updateProcessor: source.getFactory(),
sendEvents: false,
});
client.on('error', () => {
// A failed load is reported here. The tests observe the flag values instead.
});
await client.waitForInitialization({ timeout: 10 });
};

const valueIs = (value: string) => async () =>
(await client!.variation('flag', { key: 'user' }, 'default')) === value;

itOnPosix('detects each symbolic link swap', async () => {
await start();
expect(await valueIs('a')()).toBe(true);

swapTo(writeVersion('b'));
await waitFor(valueIs('b'), 3000);

swapTo(writeVersion('c'));
await waitFor(valueIs('c'), 3000);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import * as fs from 'fs';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New: real-filesystem test through LDClientNode. Deletes and recreates the directory, then checks that a later edit is still detected.

import * as os from 'os';
import * as path from 'path';

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

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

// These tests run on real files and real timers. The source waits for change notifications to
// settle for 100 ms and retries a failed load after one second.

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

const settle = (ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});

async function waitFor(condition: () => Promise<boolean>, timeoutMs: number = 5000) {
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 settle(20);
}
throw new Error('timed out waiting for the condition');
}

describe('given a file data source with automatic updates over a real directory', () => {
let base: string;
let directory: string;
let filePath: string;
let client: LDClientNode | undefined;

beforeEach(() => {
base = fs.mkdtempSync(path.join(os.tmpdir(), 'file-data-source-test-'));
directory = path.join(base, 'data');
filePath = path.join(directory, 'flags.json');
fs.mkdirSync(directory);
});

afterEach(() => {
client?.close();
client = undefined;
fs.rmSync(base, { recursive: true, force: true });
});

const start = async () => {
const source = new integrations.FileDataSourceFactory({ paths: [filePath], autoUpdate: true });
client = new LDClientNode('sdk-key', {
updateProcessor: source.getFactory(),
sendEvents: false,
});
client.on('error', () => {
// A failed load is reported here. The tests observe the flag values instead.
});
await client.waitForInitialization({ timeout: 10 });
};

const valueIs = (value: string) => async () =>
(await client!.variation('flag', { key: 'user' }, 'default')) === value;

it('detects edits after the directory is deleted and created again', async () => {
fs.writeFileSync(filePath, document('a'));
await start();
expect(await valueIs('a')()).toBe(true);

fs.rmSync(directory, { recursive: true });
await settle(300);
expect(await valueIs('a')()).toBe(true);

// The directory comes back with the file. The retry or the new watch loads it.
fs.mkdirSync(directory);
fs.writeFileSync(filePath, document('b'));
await waitFor(valueIs('b'));

// Only a watch on the new directory can detect a later edit.
fs.writeFileSync(filePath, document('c'));
await waitFor(valueIs('c'), 3000);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

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

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

// A configured path can be a symbolic link to a file in another directory. The directory that
// contains the link never reports a change to the target, so the source also watches the path
// itself, which follows the link. Symbolic links need privileges on Windows, so the test runs
// elsewhere.

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

const settle = (ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});

async function waitFor(condition: () => Promise<boolean>, timeoutMs: number = 5000) {
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 settle(20);
}
throw new Error('timed out waiting for the condition');
}

const itOnPosix = process.platform === 'win32' ? it.skip : it;

describe('given a file data source whose configured path links to a file elsewhere', () => {
let base: string;
let target: string;
let link: string;
let client: LDClientNode | undefined;

const replaceTarget = (value: string) => {
const temporary = `${target}.tmp`;
fs.writeFileSync(temporary, document(value));
fs.renameSync(temporary, target);
};

beforeEach(() => {
base = fs.mkdtempSync(path.join(os.tmpdir(), 'file-data-source-symlink-'));
fs.mkdirSync(path.join(base, 'real'));
fs.mkdirSync(path.join(base, 'config'));
target = path.join(base, 'real', 'flags.json');
link = path.join(base, 'config', 'flags.json');
fs.writeFileSync(target, document('a'));
fs.symlinkSync(path.join('..', 'real', 'flags.json'), link);
});

afterEach(() => {
client?.close();
client = undefined;
fs.rmSync(base, { recursive: true, force: true });
});

const start = async () => {
const source = new integrations.FileDataSourceFactory({ paths: [link], autoUpdate: true });
client = new LDClientNode('sdk-key', {
updateProcessor: source.getFactory(),
sendEvents: false,
});
client.on('error', () => {
// A failed load is reported here. The tests observe the flag values instead.
});
await client.waitForInitialization({ timeout: 10 });
};

const valueIs = (value: string) => async () =>
(await client!.variation('flag', { key: 'user' }, 'default')) === value;

itOnPosix('detects edits and replacements of the target', async () => {
await start();
expect(await valueIs('a')()).toBe(true);

// An in-place edit of the target.
fs.writeFileSync(target, document('b'));
await waitFor(valueIs('b'), 3000);

// An atomic replacement of the target, twice: the direct watch is renewed after each.
replaceTarget('c');
await waitFor(valueIs('c'), 3000);
replaceTarget('d');
await waitFor(valueIs('d'), 3000);
});
});
Loading
Loading