Skip to content
Open
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
29 changes: 0 additions & 29 deletions .github/workflows/ci.yml

This file was deleted.

135 changes: 125 additions & 10 deletions azure-pipelines.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,131 @@
trigger:
- main
- preview
- release/*
branches:
include:
- main
- preview
- release/*

pr:
- main
- preview
- release/*
branches:
include:
- main
- preview
- release/*

pool:
vmImage: ubuntu-latest
# The current Playwright version does not support Chromium on Ubuntu 26.04.
vmImage: ubuntu-24.04

steps:
- script: echo "Hello from Azure Pipelines"
displayName: Hello world
stages:
- stage: Build
displayName: Build and offline tests
jobs:
- job: NodeTests
displayName: Node.js tests
strategy:
matrix:
Node20:
nodeVersion: 20.x
Node22:
nodeVersion: 22.x
Node24:
nodeVersion: 24.x
steps:
- checkout: self
- task: UseNode@1
displayName: Use Node.js $(nodeVersion)
inputs:
version: $(nodeVersion)
- script: npm ci
displayName: Install dependencies
- script: npm run lint
displayName: Lint
- script: npm run build
displayName: Build
- script: npm test -- --reporter xunit --reporter-option "output=$(Agent.TempDirectory)/test-results/unit-tests.xml"
displayName: Run Node.js unit tests
- script: npm run test-browser -- --reporter=default --reporter=junit --outputFile="$(Agent.TempDirectory)/test-results/browser-tests.xml"
displayName: Run browser tests
- task: PublishTestResults@2
displayName: Publish unit and browser test results
condition: succeededOrFailed()
inputs:
testResultsFormat: JUnit
testResultsFiles: |
unit-tests.xml
browser-tests.xml
searchFolder: $(Agent.TempDirectory)/test-results
mergeTestResults: true
failTaskOnFailedTests: true
failTaskOnFailureToPublishResults: true
failTaskOnMissingResultsFile: true
testRunTitle: Node.js $(nodeVersion) unit and browser tests

- stage: Integration
displayName: Live integration tests
dependsOn: Build
jobs:
- job: LiveTests
displayName: Node.js live integration tests
timeoutInMinutes: '15'
steps:
- checkout: self
- task: UseNode@1
displayName: Use Node.js 24
inputs:
version: 24.x
- script: npm ci
displayName: Install dependencies
- task: AzureCLI@2
displayName: Run integration tests with the service principal
inputs:
azureSubscription: 'JS Provider GitHub PR Integration Test'
addSpnToEnvironment: true
visibleAzLogin: false
scriptType: pscore
scriptLocation: inlineScript
workingDirectory: $(Build.SourcesDirectory)
inlineScript: |
foreach ($name in @("servicePrincipalId", "tenantId", "idToken")) {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Missing $name. Configure the service connection with workload identity federation."
}
}
if ($env:AZURE_CLIENT_SECRET -or $env:AZURE_CLIENT_CERTIFICATE_PATH) {
throw "Do not configure client secrets or certificates for this federated test job."
}

$env:AZURE_CLIENT_ID = $env:servicePrincipalId
$env:AZURE_TENANT_ID = $env:tenantId
$tokenPath = Join-Path $env:AGENT_TEMPDIRECTORY "appconfig-federated-$([Guid]::NewGuid().ToString('N')).txt"

try {
Set-Content -LiteralPath $tokenPath -Value $env:idToken -NoNewline -Encoding utf8
$env:AZURE_FEDERATED_TOKEN_FILE = $tokenPath
$env:AZURE_SUBSCRIPTION_ID = az account show --query id --output tsv --only-show-errors
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($env:AZURE_SUBSCRIPTION_ID)) {
throw "Could not read the subscription selected by the service connection."
}

$resultsPath = Join-Path $env:AGENT_TEMPDIRECTORY "test-results/integration-tests.xml"
npm run test:integration -- --reporter xunit --reporter-option "output=$resultsPath"
if ($LASTEXITCODE -ne 0) {
throw "Integration tests failed with exit code $LASTEXITCODE."
}
} finally {
$env:AZURE_FEDERATED_TOKEN_FILE = $null
if (Test-Path -LiteralPath $tokenPath) {
Remove-Item -LiteralPath $tokenPath -Force
}
}
- task: PublishTestResults@2
displayName: Publish integration test results
condition: succeededOrFailed()
inputs:
testResultsFormat: JUnit
testResultsFiles: integration-tests.xml
searchFolder: $(Agent.TempDirectory)/test-results
failTaskOnFailedTests: true
failTaskOnFailureToPublishResults: true
failTaskOnMissingResultsFile: true
testRunTitle: Node.js 24 live integration tests
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"lint": "eslint src/ test/ examples/ --ext .js,.ts,.mjs",
"fix-lint": "eslint src/ test/ examples/ --fix --ext .js,.ts,.mjs",
"test": "mocha out/esm/test/*.test.js out/commonjs/test/*.test.js --parallel --timeout 100000",
"test:integration": "npm run build-test-esm && mocha \"out/esm/test/integration/*.test.js\" --no-parallel --timeout 120000",
"test-browser": "npx playwright install chromium && vitest --config vitest.browser.config.ts run"
},
"repository": {
Expand Down
147 changes: 147 additions & 0 deletions test/integration/featureFlag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { setTimeout as delay } from "node:timers/promises";
import { assert } from "chai";
import { DefaultAzureCredential } from "@azure/identity";
import { load } from "../../src/index.js";
import type { AzureAppConfiguration } from "../../src/index.js";
import { createNotModifiedResponseTracker, INTEGRATION_TEST_ENDPOINT as endpoint, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./helpers.js";

interface FeatureFlag {
id: string;
enabled: boolean;
conditions: {
client_filters: unknown[];
requirement_type: "Any" | "All";
};
}

function getFeatureFlags(settings: AzureAppConfiguration): FeatureFlag[] {
const featureManagement = settings.get<{ feature_flags: FeatureFlag[] }>("feature_management");
if (featureManagement === undefined) {
throw new Error("Expected the provider to return feature_management.feature_flags.");
}
assert.hasAllKeys(featureManagement, ["feature_flags"]);
assert.isArray(featureManagement.feature_flags);
return featureManagement.feature_flags;
}

describe("integration: feature flags", function () {
let credential: DefaultAzureCredential;
let context: IntegrationTestContext;

before(() => {
credential = new DefaultAzureCredential();
});

beforeEach(() => {
context = new IntegrationTestContext(endpoint, credential);
console.info(`Integration test key prefix: ${context.keyPrefix}`);
});

afterEach(async () => {
await context.cleanup();
});

it("loads only flags matching the selected IDs and label", async () => {
await context.setSetting("message", "Ordinary value");
await context.setFeatureFlag("selected-enabled", true, { label: "production" });
await context.setFeatureFlag("selected-enabled", false, { label: "development" });
await context.setFeatureFlag("selected-disabled", false, { label: "production" });
await context.setFeatureFlag("excluded", true, { label: "production" });

const settings = await load(endpoint, credential, {
selectors: [{ keyFilter: context.key("*") }],
featureFlagOptions: {
enabled: true,
selectors: [{ keyFilter: context.key("selected-*"), labelFilter: "production" }]
}
});

assert.sameDeepMembers(getFeatureFlags(settings), [
{
id: context.key("selected-enabled"),
enabled: true,
conditions: { client_filters: [], requirement_type: "Any" }
},
{
id: context.key("selected-disabled"),
enabled: false,
conditions: { client_filters: [], requirement_type: "Any" }
}
]);
assert.equal(settings.get(context.key("message")), "Ordinary value");
assert.sameMembers([...settings.keys()], [context.key("message"), "feature_management"]);
});

it("refreshes changed, added, and deleted flags independently of ordinary settings", async () => {
await context.setSetting("message", "Initial value");
await context.setFeatureFlag("updated", true);
await context.setFeatureFlag("removed", false);
const notModified = createNotModifiedResponseTracker();
const settings = await load(endpoint, credential, {
selectors: [{ keyFilter: context.key("*") }],
clientOptions: {
additionalPolicies: [{ policy: notModified.policy, position: "perCall" }]
},
featureFlagOptions: {
enabled: true,
selectors: [{ keyFilter: context.key("*") }],
refresh: { enabled: true, refreshIntervalInMs: REFRESH_INTERVAL_IN_MS }
}
});
assert.sameDeepMembers(getFeatureFlags(settings), [
{
id: context.key("updated"),
enabled: true,
conditions: { client_filters: [], requirement_type: "Any" }
},
{
id: context.key("removed"),
enabled: false,
conditions: { client_filters: [], requirement_type: "Any" }
}
]);
assert.equal(settings.get(context.key("message")), "Initial value");

let notifications = 0;
const listener = settings.onRefresh(() => notifications++);
try {
await context.setSetting("message", "Updated ordinary value");
await context.setFeatureFlag("updated", false);
await context.setFeatureFlag("added", true);
await context.deleteFeatureFlag("removed");

await refreshUntil(settings, () => {
const flags = getFeatureFlags(settings);
return flags.some(flag => flag.id === context.key("updated") && !flag.enabled) &&
flags.some(flag => flag.id === context.key("added") && flag.enabled) &&
!flags.some(flag => flag.id === context.key("removed"));
}, "feature flag updates, additions, and deletions");

assert.sameDeepMembers(getFeatureFlags(settings), [
{
id: context.key("updated"),
enabled: false,
conditions: { client_filters: [], requirement_type: "Any" }
},
{
id: context.key("added"),
enabled: true,
conditions: { client_filters: [], requirement_type: "Any" }
}
]);
assert.equal(settings.get(context.key("message")), "Initial value");
assert.equal(notifications, 1);

const previousNotModifiedCount = notModified.count;
await delay(REFRESH_INTERVAL_IN_MS);
await settings.refresh();
assert.isAbove(notModified.count, previousNotModifiedCount);
assert.equal(notifications, 1);
} finally {
listener.dispose();
}
});
});
Loading
Loading