Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fe9ecb1
fix(api-service): format agent powered-by branding as muted footnote …
scopsy Jun 30, 2026
3b841f4
chore(root): Weekly monorepo dependency updates fixes NV-8144 (#11714)
cursor[bot] Jun 30, 2026
b452563
fix(dashboard): update broken PII security docs link fixes NV-8150 (#…
jainpawan21 Jun 30, 2026
dd91840
docs(docs): improve DPA discoverability, HMAC examples, and release n…
scopsy Jun 30, 2026
2ca626f
chore(api): bump chat SDK to 4.31.0 fixes NV-8151 (#11721)
scopsy Jun 30, 2026
7ae2354
docs(docs): replace generic icons with vendor SVGs fixes DOC-382 (#11…
scopsy Jun 30, 2026
17855e4
docs(docs): align what-is-novu with agents capabilities fixes DOC-381…
scopsy Jun 30, 2026
9582b23
feat(docs): add Mintlify Prompt components across docs fixes DOC-383 …
scopsy Jun 30, 2026
e1db35d
fix(dashboard): use root dashboard URL for trial inbox marketing CTA …
scopsy Jun 30, 2026
71c8456
feat(dashboard): support actor variables in step content editor fixes…
jainpawan21 Jun 30, 2026
1b40ddf
docs(docs): fix email references on SMS integrations page (#11732)
mintlify[bot] Jun 30, 2026
4eacbb0
docs(docs): improve SEO and GEO positioning fixes DOC-386 (#11728)
scopsy Jun 30, 2026
699300d
fix(docs): unify sidebar section title to Getting Started fixes DOC-3…
scopsy Jun 30, 2026
b0f02ad
fix(docs): unify Getting Started nav and remove AI setup callouts fix…
scopsy Jun 30, 2026
2c204e6
docs: align roles and permissions table with code fixes DOC-388 (#11736)
scopsy Jun 30, 2026
15306c8
docs(docs): update authentication and organizations docs fixes DOC-39…
scopsy Jun 30, 2026
2407405
docs(docs): expand billing page with pricing concepts fixes DOC-391 (…
scopsy Jun 30, 2026
622f76e
Fix title formatting in platform documentation
scopsy Jun 30, 2026
ad54ac7
docs(billing): add Enterprise on-premises section fixes DOC-392 (#11739)
scopsy Jun 30, 2026
7535a55
feat(docs): add telegram provider doc page fixes DOC-385 (#11729)
jainpawan21 Jun 30, 2026
3ea6d32
docs(docs): add all official SDK examples to trigger workflow pages f…
scopsy Jun 30, 2026
c858643
docs(docs): Expand and correct Novu platform glossary fixes DOC-393 (…
scopsy Jun 30, 2026
1628eff
fix(api-service): handle attachment validation, context race, and inv…
cursor[bot] Jun 30, 2026
bfc6938
docs: remove extra Use AI headings from integration pages fixes DOC-3…
scopsy Jun 30, 2026
3d0ede1
docs(docs): document inbound replies and channel handling fixes DOC-3…
scopsy Jun 30, 2026
526b1e9
docs(docs): add multi-SDK server-side examples across platform docs f…
scopsy Jul 1, 2026
f3a9632
ci(api): run novu-v2-ce e2e suite on fork PRs fixes NV-8154 (#11749)
scopsy Jul 1, 2026
80ee663
fix(docs): remove duplicate Step title and description content fixes …
scopsy Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 2 additions & 2 deletions .github/workflows/on-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,8 @@ jobs:
# secrets — even after a maintainer approves the run — so the EE branch,
# which checks out the private enterprise submodule using
# ${{ secrets.SUBMODULES_TOKEN }}, fails immediately with
# "Input required and not supplied: token". Fall back to the non-EE e2e
# suite (Novu V1) for fork PRs so the change still gets validated.
# "Input required and not supplied: token". Fall back to the community
# edition Novu V2 e2e suite for fork PRs so the change still gets validated.
ee: ${{ github.event.pull_request.head.repo.fork != true }}
secrets: inherit

Expand Down
10 changes: 7 additions & 3 deletions .github/workflows/reusable-api-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(inputs.ee && '[{"shard":1,"total":4},{"shard":2,"total":4},{"shard":3,"total":4},{"shard":4,"total":4}]' || '[{"shard":1,"total":1}]') }}
include: ${{ fromJson('[{"shard":1,"total":4},{"shard":2,"total":4},{"shard":3,"total":4},{"shard":4,"total":4}]') }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 15
permissions:
Expand Down Expand Up @@ -67,11 +67,15 @@ jobs:
shell: bash
run: wait-on --timeout=180000 http://127.0.0.1:1342/v1/health-check

- name: Run Novu V1 E2E tests
- name: Run Novu V2 CE E2E tests
if: ${{ !inputs.ee }}
env:
NOVU_V2_SHARD_INDEX: ${{ matrix.shard }}
NOVU_V2_TOTAL_SHARDS: ${{ matrix.total }}
NOVU_V2_MOCHA_REPORTER: dot
run: |
set -e
pnpm --filter @novu/api-service test:e2e:novu-v0
pnpm --filter @novu/api-service test:e2e:novu-v2-ce

- name: Run Novu V2 E2E tests
if: ${{ inputs.ee }}
Expand Down
37 changes: 19 additions & 18 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"test": "cross-env TS_NODE_PROJECT=tsconfig.spec.json TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test NOVU_ENTERPRISE=true CLERK_ENABLED=true NODE_OPTIONS=--no-experimental-strip-types mocha --timeout 15000 --require ts-node/register --exit 'src/**/*.spec.ts'",
"test:e2e:novu-v0": "cross-env TS_NODE_PROJECT=tsconfig.e2e-v0.json NODE_ENV=test NODE_OPTIONS=--no-experimental-strip-types mocha --timeout 15000 --retries 3 --grep '#novu-v0' --require ts-node/register --exit --file e2e/setup.ts src/**/*.e2e{,-ee}.ts",
"test:e2e:novu-v2": "cross-env TS_NODE_PROJECT=tsconfig.spec.json NODE_ENV=test CI_EE_TEST=true CLERK_ENABLED=true NODE_OPTIONS='--max_old_space_size=8192 --no-experimental-strip-types' node scripts/run-novu-v2-e2e-shard.cjs",
"test:e2e:novu-v2-ce": "cross-env TS_NODE_PROJECT=tsconfig.spec.json NODE_ENV=test CI_EE_TEST=false CLERK_ENABLED=true NOVU_V2_CE_ONLY=true NODE_OPTIONS='--max_old_space_size=8192 --no-experimental-strip-types' node scripts/run-novu-v2-e2e-shard.cjs",
"migration": "cross-env NODE_ENV=local MIGRATION=true ts-node --transpileOnly",
"seed:clickhouse": "cross-env NODE_ENV=local ts-node --transpileOnly scripts/seed-clickhouse.ts",
"seed:triggers": "cross-env NODE_ENV=local ts-node --transpileOnly scripts/seed-triggers.ts",
Expand All @@ -49,19 +50,19 @@
},
"dependencies": {
"@aws-sdk/client-secrets-manager": "^3.1048.0",
"@chat-adapter/slack": "4.30.0",
"@chat-adapter/state-ioredis": "4.30.0",
"@chat-adapter/teams": "4.30.0",
"@chat-adapter/telegram": "4.30.0",
"@chat-adapter/whatsapp": "4.30.0",
"@chat-adapter/slack": "4.31.0",
"@chat-adapter/state-ioredis": "4.31.0",
"@chat-adapter/teams": "4.31.0",
"@chat-adapter/telegram": "4.31.0",
"@chat-adapter/whatsapp": "4.31.0",
"@godaddy/terminus": "^4.12.1",
"@google-cloud/storage": "^6.2.3",
"@nestjs/axios": "4.0.1",
"@nestjs/common": "11.1.24",
"@nestjs/core": "11.1.24",
"@nestjs/common": "11.1.27",
"@nestjs/core": "11.1.27",
"@nestjs/jwt": "11.0.2",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-express": "11.1.24",
"@nestjs/platform-express": "11.1.27",
"@nestjs/swagger": "7.4.0",
"@nestjs/terminus": "11.1.1",
"@nestjs/throttler": "6.5.0",
Expand All @@ -78,7 +79,7 @@
"@novu/testing": "workspace:*",
"@sendgrid/mail": "^8.1.6",
"@slack/types": "2.20.1",
"@slack/web-api": "7.15.0",
"@slack/web-api": "7.17.0",
"@sentry/browser": "^8.33.1",
"@sentry/hub": "^7.114.0",
"@sentry/nestjs": "^8.49.0",
Expand All @@ -92,13 +93,13 @@
"@upstash/ratelimit": "^0.4.4",
"ajv": "^8.20.0",
"ajv-formats": "^2.1.1",
"axios": "^1.17.0",
"axios": "^1.18.1",
"bcrypt": "^5.0.0",
"body-parser": "^2.2.1",
"bull": "^4.2.1",
"chat": "4.30.0",
"chat": "4.31.0",
"class-transformer": "0.5.1",
"class-validator": "0.14.1",
"class-validator": "0.15.1",
"clickhouse-migrations": "^1.3.0",
"compression": "^1.7.4",
"context.dev": "^1.4.0",
Expand All @@ -107,7 +108,7 @@
"deep-object-diff": "^1.1.9",
"dotenv": "^16.6.1",
"entities": "^7.0.0",
"envalid": "^8.1.1",
"envalid": "^8.2.0",
"es-toolkit": "^1.47.0",
"express": "^5.2.1",
"handlebars": "4.7.9",
Expand All @@ -118,7 +119,7 @@
"json-schema-faker": "^0.5.6",
"json-schema-to-ts": "^3.0.0",
"jsonwebtoken": "9.0.3",
"liquidjs": "^10.27.0",
"liquidjs": "^10.27.1",
"lodash": "^4.18.0",
"lru-cache": "^11.5.1",
"nanoid": "^3.1.20",
Expand All @@ -136,9 +137,9 @@
"request-ip": "^3.3.0",
"rimraf": "^3.0.2",
"rxjs": "7.8.2",
"sanitize-html": "^2.4.0",
"sanitize-html": "^2.17.5",
"shortid": "^2.2.17",
"svix": "^1.64.1",
"svix": "^1.96.1",
"swagger-ui-express": "^4.4.0",
"tar": "^7.5.16",
"tldts": "^7.0.28",
Expand All @@ -149,9 +150,9 @@
},
"devDependencies": {
"@faker-js/faker": "^6.0.0",
"@nestjs/cli": "11.0.21",
"@nestjs/cli": "11.0.23",
"@nestjs/schematics": "11.1.0",
"@nestjs/testing": "11.1.24",
"@nestjs/testing": "11.1.27",
"@stoplight/spectral-cli": "^6.15.0",
"@swc-node/register": "1.10.10",
"@types/async": "^3.2.1",
Expand Down
77 changes: 60 additions & 17 deletions apps/api/scripts/run-novu-v2-e2e-shard.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,25 @@ const ROOT = path.resolve(__dirname, '..');
const DEFAULT_SHARD_INDEX = 1;
const DEFAULT_TOTAL_SHARDS = 1;
const NOVU_V2_TAG = '#novu-v2';
const TEST_FILE_PATTERN = /\.e2e(-ee)?\.ts$/;
const TEST_CASE_PATTERN = /\bit(?:\.only)?\s*\(/g;
const DEFAULT_MOCHA_REPORTER = process.env.CI ? 'dot' : 'spec';
const MOCHA_REPORTER = process.env.NOVU_V2_MOCHA_REPORTER || DEFAULT_MOCHA_REPORTER;

// CE fork PRs run with CI_EE_TEST=false, so cloud-EE-only behavior (outbound SSRF
// pinning, Stripe billing periods, RBAC permissions, translations, novu-app MCP)
// is unavailable. Exclude mixed files that contain those tests.
const CE_EXCLUDED_FILES = new Set([
'src/app/agents/e2e/active-conversations.e2e.ts',
'src/app/agents/e2e/agent-mcp-servers.e2e.ts',
'src/app/auth/e2e/permissions.guard.e2e.ts',
'src/app/bridge/e2e/sync.e2e.ts',
'src/app/environments-v1/e2e/api-key-environments-exposure.e2e.ts',
'src/app/environments-v2/e2e/environments-v2-diff.e2e.ts',
'src/app/events/e2e/trigger-event-ssrf.e2e.ts',
'src/app/organization/e2e/update-organization-settings.e2e.ts',
'src/app/workflows-v2/e2e/test-http-endpoint.e2e.ts',
]);

const MOCHA_ARGS = [
'--timeout',
'30000',
Expand Down Expand Up @@ -42,7 +56,33 @@ function readSortedEntries(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
}

function collectTestFiles(dir, files = []) {
function getCliArgs() {
return process.argv.slice(2).filter((arg) => arg !== '--');
}

function parseCeOnly() {
const args = getCliArgs();

return args.includes('--ce-only') || process.env.NOVU_V2_CE_ONLY === 'true';
}

function getTestFilePattern(ceOnly) {
return ceOnly ? /\.e2e\.ts$/ : /\.e2e(-ee)?\.ts$/;
}

function collectTestFileRoots(ceOnly) {
const roots = [path.join(ROOT, 'src')];

if (!ceOnly) {
roots.push(path.join(ROOT, 'e2e', 'enterprise'));
}

return roots;
}

function collectTestFiles(ceOnly, dir, files = []) {
const testFilePattern = getTestFilePattern(ceOnly);

if (!fs.existsSync(dir)) {
return files;
}
Expand All @@ -51,22 +91,18 @@ function collectTestFiles(dir, files = []) {
const fullPath = path.join(dir, entry.name);

if (entry.isDirectory()) {
collectTestFiles(fullPath, files);
collectTestFiles(ceOnly, fullPath, files);
continue;
}

if (TEST_FILE_PATTERN.test(entry.name)) {
if (testFilePattern.test(entry.name)) {
files.push(toPosixPath(path.relative(ROOT, fullPath)));
}
}

return files;
}

function getCliArgs() {
return process.argv.slice(2).filter((arg) => arg !== '--');
}

function parseShardValue(rawValue) {
const [shardIndex, totalShards] = rawValue.split('/').map((value) => Number(value));

Expand Down Expand Up @@ -128,14 +164,19 @@ function compareWeightedFiles(left, right) {
return right.weight - left.weight || compareFileNames(left.relativePath, right.relativePath);
}

function collectWeightedFiles() {
const candidates = [
...collectTestFiles(path.join(ROOT, 'src')),
...collectTestFiles(path.join(ROOT, 'e2e', 'enterprise')),
];
function isCeExcludedFile(relativePath) {
return CE_EXCLUDED_FILES.has(relativePath);
}

function collectWeightedFiles(ceOnly) {
const candidates = collectTestFileRoots(ceOnly).flatMap((dir) => collectTestFiles(ceOnly, dir));

return candidates
.map((relativePath) => {
if (ceOnly && isCeExcludedFile(relativePath)) {
return null;
}

const source = readSource(relativePath);

if (!source.includes(NOVU_V2_TAG)) {
Expand Down Expand Up @@ -186,8 +227,9 @@ function getShard(weightedFiles, shardIndex, totalShards) {
return buildShards(weightedFiles, totalShards)[shardIndex - 1];
}

function printShardSummary(shardIndex, totalShards, shard) {
console.log(`Running Novu V2 E2E shard ${shardIndex}/${totalShards} with ${shard.files.length} files (weight ${shard.weight}).`);
function printShardSummary(shardIndex, totalShards, shard, ceOnly) {
const suiteLabel = ceOnly ? 'Novu V2 CE E2E' : 'Novu V2 E2E';
console.log(`Running ${suiteLabel} shard ${shardIndex}/${totalShards} with ${shard.files.length} files (weight ${shard.weight}).`);
}

function runMocha(filePaths) {
Expand All @@ -201,14 +243,15 @@ function runMocha(filePaths) {
function run() {
applyDefaultEnv();

const ceOnly = parseCeOnly();
const { listOnly, shardIndex, totalShards } = parseShardConfig();
const shard = getShard(collectWeightedFiles(), shardIndex, totalShards);
const shard = getShard(collectWeightedFiles(ceOnly), shardIndex, totalShards);

if (!shard || shard.files.length === 0) {
throw new Error(`No files assigned to shard ${shardIndex}/${totalShards}`);
}

printShardSummary(shardIndex, totalShards, shard);
printShardSummary(shardIndex, totalShards, shard, ceOnly);

if (listOnly) {
for (const file of shard.files) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { AgentConfigResolver, ResolvedAgentConfig } from '../../channels/agent-c
import type { ReplyContentDto } from '../../shared/dtos/agent-reply-payload.dto';
import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum';
import { esmImport } from '../../shared/util/esm-import';
import { buildPoweredByWatermark, contentHasPoweredByWatermark } from '../../shared/util/novu-powered-by-watermark';
import {
buildBrandedMarkdownReply,
contentHasPoweredByWatermark,
} from '../../shared/util/novu-powered-by-watermark';
import { type AgentActionTokenBinding, AgentActionTokenService } from '../action-token/agent-action-token.service';
import { AgentConversationService } from '../conversation/agent-conversation.service';
import { ChatInstanceRegistry } from '../ingress/chat-instance.registry';
Expand Down Expand Up @@ -509,10 +512,10 @@ export class OutboundGateway {
}

/**
* Appends the "Powered by Novu" watermark as the last line of outbound text
* messages for organizations that have not removed Novu branding (free plan).
* Pro and above can disable it via the existing `removeNovuBranding` org
* setting, resolved once per delivery by `AgentConfigResolver`.
* Wraps outbound markdown replies with a muted "Powered by Novu" footnote for
* organizations that have not removed Novu branding (free plan). Pro and above
* can disable it via the existing `removeNovuBranding` org setting, resolved
* once per delivery by `AgentConfigResolver`.
*
* Only plain markdown replies are branded — cards/action messages are left
* untouched.
Expand All @@ -526,9 +529,9 @@ export class OutboundGateway {
return content;
}

const watermark = buildPoweredByWatermark(branding.agentIdentifier, branding.platform);
const card = buildBrandedMarkdownReply(content.markdown, branding.agentIdentifier, branding.platform);

return { ...content, markdown: `${content.markdown}\n\n${watermark}` };
return { ...content, card: card as unknown as Record<string, unknown>, markdown: undefined };
}

/**
Expand Down
Loading
Loading