Merge remote-tracking branch 'origin/develop' into visual-diff

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yaoweiprcandClaude Opus 5.5 committed 2026-10-08 16:24:19 +08:00
commit 466ecc8ca6
298 files changed
+12432 -6807

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
---
name: KSAI
permissions: {}
on: # yamllint disable-line rule:truthy
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request:
types: [review_requested, opened, ready_for_review]
pull_request_review:
types: [submitted]
workflow_dispatch:
inputs:
pr:
description: Pull request number to review
required: false
actor:
description: GitHub username to authorize against CODEOWNERS, matching comment authorization
required: false
issue_number:
description: Issue number for plan continuation; empty when work_ref names the work
required: false
work_ref:
description: The work to do when there is no GitHub issue, as jira/<KEY>
required: false
attempt:
description: Runs already spent by this continuation chain
required: false
stall:
description: Consecutive runs that completed no step
required: false
prev_remaining:
description: Unchecked boxes seen by the predecessor, with empty distinct from zero
required: false
comment_id:
description: Comment a dispatched request answers, read back from GitHub rather than believed
required: false
comment_kind:
description: Which space comment_id came from, issue, review or submitted_review
required: false
record_id:
description: Dispatch record written for this run, read back over the run's own OIDC token
required: false
jobs:
ksai:
name: run
if: >-
(github.event_name == 'workflow_dispatch' &&
(inputs.pr != '' || inputs.issue_number != '' || inputs.work_ref != '' ||
inputs.comment_id != '' || inputs.comment_kind != '')) ||
((github.event_name == 'issue_comment' ||
github.event_name == 'pull_request_review_comment') &&
github.event.comment.user.type != 'Bot' &&
(contains(github.event.comment.body, '/ksai') ||
(vars.KSAI_TRIGGER_PHRASE != '' &&
contains(github.event.comment.body, vars.KSAI_TRIGGER_PHRASE)))) ||
(github.event_name == 'issue_comment' &&
github.event.comment.user.type != 'Bot' &&
github.event.issue.pull_request &&
github.event.issue.user.type == 'Bot') ||
(github.event_name == 'pull_request_review_comment' &&
github.event.comment.user.type != 'Bot' &&
github.event.comment.in_reply_to_id &&
github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'pull_request_review' &&
github.event.action == 'submitted' &&
github.event.review.user.type != 'Bot' &&
github.event.pull_request.draft == true &&
github.event.pull_request.user.type == 'Bot' &&
github.event.pull_request.head.repo.full_name == github.repository)
uses: Kong/ksai-public/.github/workflows/ksai.yml@15f45eae5f0794b4e3f63875e9a8305fc9e60215 # v3.106.0
permissions:
contents: write # checked out by every command, and pushed by one that changes code
issues: write # every command answers with a comment, and reacts to the one that asked
pull-requests: write # post reviews and maintain draft pull request plans
id-token: write # mint the GitHub OIDC token for federation
checks: read # read what CI said about the head
statuses: read # the older API a build may report through
actions: write # old caller review dispatch, successor plan runs and test publication
with:
trigger_phrase: ${{ vars.KSAI_TRIGGER_PHRASE }}
anthropic_base_url: ${{ vars.KSAI_ANTHROPIC_BASE_URL }}
federation_rule_id: ${{ vars.KSAI_FEDERATION_RULE_ID }}
organization_id: ${{ vars.KSAI_ORGANIZATION_ID }}
service_account_id: ${{ vars.KSAI_SERVICE_ACCOUNT_ID }}
workspace_id: ${{ vars.KSAI_WORKSPACE_ID }}
push_as_app: false
pr: ${{ inputs.pr }}
actor: ${{ inputs.actor }}
issue_number: ${{ inputs.issue_number }}
work_ref: ${{ inputs.work_ref }}
attempt: ${{ inputs.attempt }}
stall: ${{ inputs.stall }}
prev_remaining: ${{ inputs.prev_remaining }}
comment_id: ${{ inputs.comment_id }}
comment_kind: ${{ inputs.comment_kind }}
record_id: ${{ inputs.record_id }}
request:
if: >-
(github.event_name == 'pull_request' &&
github.event.action == 'review_requested' &&
github.event.requested_team.slug == (vars.KSAI_REVIEW_TEAM || 'ksai') &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.sender.type != 'Bot') ||
(github.event_name == 'pull_request' &&
contains(fromJSON('["opened", "ready_for_review"]'), github.event.action) &&
github.event.pull_request.head.repo.full_name == github.repository)
uses: Kong/ksai-public/.github/workflows/ksai-request.yml@15f45eae5f0794b4e3f63875e9a8305fc9e60215 # v3.106.0
permissions:
actions: write # dispatch this workflow onto the default branch
contents: read # download the actions this call runs
with:
push_as_app: false
+2
View File
@@ -392,6 +392,8 @@ jobs:
- name: Checkout branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Get release version
id: release_version
+2
View File
@@ -48,6 +48,8 @@ jobs:
- name: Checkout branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+1
View File
@@ -36,6 +36,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
ref: develop
fetch-depth: 0
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+2
View File
@@ -27,4 +27,6 @@ jobs:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: Kong/public-shared-actions/security-actions/semgrep@a18abf762d6e2444bcbfd20de70451ea1e3bc1b1 # 4.0.1
+2
View File
@@ -32,6 +32,8 @@ jobs:
- name: Checkout branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+77 -1
View File
@@ -78,6 +78,8 @@ jobs:
- name: Checkout branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -100,8 +102,82 @@ jobs:
name: app-build
path: packages/insomnia/build/
- name: Download previous Playwright last run state
if: ${{ github.run_attempt > 1 }}
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: playwright-last-run-shard-${{ matrix.shardIndex }}-attempt-*
path: .playwright-last-run-history
- name: Restore previous Playwright last run state
if: ${{ github.run_attempt > 1 }}
env:
SHARD_INDEX: ${{ matrix.shardIndex }}
SHARD_TOTAL: ${{ matrix.shardTotal }}
run: |
set -euo pipefail
CURRENT_ATTEMPT="${GITHUB_RUN_ATTEMPT}"
PREVIOUS_ATTEMPT=$((CURRENT_ATTEMPT - 1))
mkdir -p packages/insomnia-smoke-test/traces
if [ ! -d .playwright-last-run-history ]; then
echo "::notice::No last-run history downloaded for shard $SHARD_INDEX/$SHARD_TOTAL. Falling back to full shard run."
exit 0
fi
CANDIDATE_FILE="$(
find .playwright-last-run-history -type f \( -name '.last-run.json' -o -path '*/traces/.last-run.json' \) \
| while IFS= read -r file; do
if [[ "$file" =~ attempt-([0-9]+) ]]; then
attempt="${BASH_REMATCH[1]}"
else
attempt="$PREVIOUS_ATTEMPT"
fi
if [ "$attempt" -lt "$CURRENT_ATTEMPT" ]; then
printf '%s\t%s\n' "$attempt" "$file"
fi
done \
| sort -t $'\t' -k1,1nr \
| head -n1 \
| cut -f2
)"
if [ -z "${CANDIDATE_FILE:-}" ]; then
echo "::notice::No previous last-run state found for shard $SHARD_INDEX/$SHARD_TOTAL. Falling back to full shard run."
exit 0
fi
cp "$CANDIDATE_FILE" packages/insomnia-smoke-test/traces/.last-run.json
echo "Restored Playwright last-run state from: $CANDIDATE_FILE"
- name: Smoke test electron app (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
run: npm run test:build -w packages/insomnia-smoke-test -- --project=Smoke --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
run: |
LAST_FAILED_ARGS=()
if [ "${GITHUB_RUN_ATTEMPT:-1}" -gt 1 ]; then
if [ ! -f packages/insomnia-smoke-test/traces/.last-run.json ]; then
echo "::notice::Rerun attempt detected but no previous .last-run.json for shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}. Running full shard."
elif node -e "const fs=require('node:fs'); const data=JSON.parse(fs.readFileSync('packages/insomnia-smoke-test/traces/.last-run.json','utf8')); process.exit(Array.isArray(data.failedTests) && data.failedTests.length > 0 ? 0 : 1)"; then
echo "Rerun attempt detected. Running only last failed tests for shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}."
LAST_FAILED_ARGS+=(--last-failed)
else
echo "::notice::No failed tests recorded in previous .last-run.json for shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}. Running full shard."
fi
fi
npm run test:build -w packages/insomnia-smoke-test -- --project=Smoke --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} "${LAST_FAILED_ARGS[@]}"
- name: Upload Playwright last run state
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
if-no-files-found: warn
name: playwright-last-run-shard-${{ matrix.shardIndex }}-attempt-${{ github.run_attempt }}
path: packages/insomnia-smoke-test/traces/.last-run.json
include-hidden-files: true
retention-days: 7
- name: Upload smoke test traces
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+71 -207
View File
@@ -103,228 +103,92 @@ jobs:
if: github.event_name != 'pull_request' || steps.vendor-changed.outputs.changed == 'true'
run: npm run sandbox:vendored:test-regression -w insomnia
- name: Checkout base branch (cycle comparison)
if: github.event_name == 'pull_request' && always()
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.ref }}
path: insomnia-base
- name: Check circular references
id: check-cycles
continue-on-error: true
shell: bash
# Exit code 1 (a new cycle) and 2 (a stale baseline) are reported differently below, so
# capture it rather than letting `-e` collapse both into a generic step failure.
run: |
set +e
npm run check-cycle-references | tee cycle-report.txt
status=${PIPESTATUS[0]}
set -e
echo "status=$status" >> "$GITHUB_OUTPUT"
exit "$status"
- name: Setup Node (base branch tree)
if: github.event_name == 'pull_request' && always()
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: insomnia-base/.nvmrc
cache: npm
cache-dependency-path: insomnia-base/package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@kong'
- name: Install packages (base branch tree)
if: github.event_name == 'pull_request' && always()
working-directory: insomnia-base
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Check Circular References
if: github.event_name == 'pull_request' && always()
- name: Post circular references PR comment
if: github.event_name == 'pull_request' && always() && steps.check-cycles.outcome != 'skipped'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const path = require('path');
const fs = require('fs');
const workspaceRoot = process.env.GITHUB_WORKSPACE;
const prPackagesDir = path.join(workspaceRoot, 'packages');
const basePackagesDir = path.join(workspaceRoot, 'insomnia-base', 'packages');
// 0 = matches baseline, 1 = a new cycle appeared, 2 = baseline is stale. Anything
// else means the check itself blew up, which is not a claim about cycles either way.
const status = '${{ steps.check-cycles.outputs.status }}';
const passed = status === '0';
const summaryByStatus = {
'0': { icon: '✅', text: 'No new circular references' },
'1': { icon: '⚠️', text: 'New circular references detected' },
'2': { icon: '♻️', text: 'No new circular references, but the baseline is stale — regenerate and commit it' },
};
const { icon, text } = summaryByStatus[status] ?? { icon: '💥', text: 'The circular reference check failed to run' };
const report = fs.readFileSync('cycle-report.txt', 'utf8').trim();
const markdownContent = `<details>
<summary>${icon} Circular References Report</summary>
async function analyzeCircularReferences(rootDir) {
const madge = require('madge');
const res = await madge(rootDir, {
fileExtensions: ['ts', 'tsx'],
});
return res.circular();
}
// Function to generate markdown report
function generateMarkdownReport(prCircularRefs, baseCircularRefs, prCount, baseCount, baseBranch) {
const timestamp = new Date().toISOString();
const diff = prCount - baseCount;
const diffPercent = baseCount > 0 ? ((diff / baseCount) * 100).toFixed(2) : '0.00';
let status = '✅ PASSED';
let statusEmoji = '✅';
if (diff > 0) {
status = '⚠️ WARNING';
statusEmoji = '⚠️';
} else if (diff === 0) {
status = '✅ NO CHANGE';
statusEmoji = '✅';
} else {
status = '✨ IMPROVED';
statusEmoji = '✨';
}
// Find new and removed circular references
const prCycleStrings = new Set(prCircularRefs.map(cycle => cycle.join(' -> ')));
const baseCycleStrings = new Set(baseCircularRefs.map(cycle => cycle.join(' -> ')));
const newCycles = Array.from(prCycleStrings).filter(cycle => !baseCycleStrings.has(cycle)).sort();
const removedCycles = Array.from(baseCycleStrings).filter(cycle => !prCycleStrings.has(cycle)).sort();
return `# ${statusEmoji} Circular References Report
**Generated at:** ${timestamp}
**Status:** ${status}
## Summary
| Metric | Base (\`${baseBranch}\`) | PR | Change |
|--------|----------------|-----|---------|
| Total Circular References | ${baseCount} | ${prCount} | ${diff > 0 ? '+' : ''}${diff} (${diffPercent > 0 ? '+' : ''}${diffPercent}%) |
${newCycles.length > 0 ? `
## ⚠️ New Circular References Added (${newCycles.length})
<details open>
<summary>Click to expand/collapse</summary>
**Status:** ${text}
\`\`\`
${newCycles.join('\n')}
${report}
\`\`\`
</details>
` : ''}
${removedCycles.length > 0 ? `
## ✨ Circular References Removed (${removedCycles.length})
<details open>
<summary>Click to expand/collapse</summary>
\`\`\`
${removedCycles.join('\n')}
\`\`\`
</details>
` : ''}
<details>
<summary>Click to view all circular references in PR (${prCount})</summary>
\`\`\`
${prCircularRefs.length > 0 ? prCircularRefs.sort().map(cycle => cycle.join(' -> ')).join('\n') : 'No circular references found'}
\`\`\`
</details>
<details>
<summary>Click to view all circular references in base branch (${baseCount})</summary>
\`\`\`
${baseCircularRefs.length > 0 ? baseCircularRefs.sort().map(cycle => cycle.join(' -> ')).join('\n') : 'No circular references found'}
\`\`\`
</details>
## Analysis
${
diff > 0
? `⚠️ **Warning:** This PR introduces ${diff} new circular ${diff === 1 ? 'reference' : 'references'}. Consider refactoring to avoid adding circular dependencies.`
: diff < 0
? `✨ **Great Job!** This PR removes ${Math.abs(diff)} circular ${Math.abs(diff) === 1 ? 'reference' : 'references'}. Keep up the good work!`
: `✅ **No Change:** This PR does not introduce or remove any circular references.`
}
---
*This report was generated automatically by comparing against the \`${baseBranch}\` branch.*
`;
}
try {
const baseBranch = context.payload.pull_request.base.ref;
console.log(`Base branch (PR target): ${baseBranch}`);
console.log(`PR packages dir: ${prPackagesDir}`);
console.log(`Base packages dir: ${basePackagesDir}`);
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
console.log('Analyzing circular references in PR (merge) tree...');
const prCircularRefs = await analyzeCircularReferences(prPackagesDir);
const prCount = prCircularRefs.length;
console.log(`PR: Found ${prCount} circular references`);
const botComment = comments.find(comment =>
comment.user?.type === 'Bot' &&
comment.body?.includes('Circular References Report')
);
console.log(`Analyzing circular references in ${baseBranch} tree...`);
const baseCircularRefs = await analyzeCircularReferences(basePackagesDir);
const baseCount = baseCircularRefs.length;
console.log(`Base: Found ${baseCount} circular references`);
// Generate report
const diff = prCount - baseCount;
const markdownContent = generateMarkdownReport(prCircularRefs, baseCircularRefs, prCount, baseCount, baseBranch);
// Post PR comment (skip creating/updating if there's no change in circular references,
// but clean up a stale comment from a previous run where there was a change)
if (context.eventName === 'pull_request') {
try {
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(comment =>
comment.user?.type === 'Bot' &&
comment.body?.includes('# ') &&
comment.body?.includes('Circular References Report')
);
if (diff === 0) {
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
console.log('Deleted stale PR comment (no change)');
} else {
console.log('No change in circular references, skipping PR comment');
}
} else if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: markdownContent
});
console.log('Updated existing PR comment');
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: markdownContent
});
console.log('Created new PR comment');
}
} catch (error) {
console.error('Error posting PR comment:', error);
}
}
// Log results but don't fail the build
if (diff > 0) {
console.log(`⚠️ Warning: This PR introduces ${diff} new circular ${diff === 1 ? 'reference' : 'references'}. Base: ${baseCount}, PR: ${prCount}`);
} else if (diff < 0) {
console.log(`✨ Great! This PR removes ${Math.abs(diff)} circular ${Math.abs(diff) === 1 ? 'reference' : 'references'}!`);
if (passed) {
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
console.log('Deleted stale PR comment (no new circular references)');
} else {
console.log(`✅ No change in circular references (${prCount})`);
console.log('No new circular references, skipping PR comment');
}
} catch (error) {
console.error('Error analyzing circular references:', error);
core.setFailed('Failed to analyze circular references');
} else if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: markdownContent
});
console.log('Updated existing PR comment');
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: markdownContent
});
console.log('Created new PR comment');
}
# Fails on a stale baseline too, not just on new cycles: the baseline is a ratchet, so a
# cycle left in it after being fixed would silently keep permitting its reintroduction.
- name: Fail if the circular reference check did not pass
if: steps.check-cycles.outcome == 'failure'
run: exit 1
+385 -1080
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -45,7 +45,8 @@
"test:crit:package": "npm run test:package -w insomnia-smoke-test -- --project=Critical",
"test:crit:dev": "npm run test:dev -w insomnia-smoke-test -- --project=Critical",
"postinstall": "patch-package && npm run verify-bundle-plugins -w insomnia && npm run install-libcurl-electron",
"check-cycle-references": "madge --circular --extensions ts,tsx packages"
"check-cycle-references": "node scripts/circular-references/check-cycle-references.mjs",
"check-cycle-references:baseline": "node scripts/circular-references/check-cycle-references.mjs --update-baseline"
},
"devDependencies": {
"@develohpanda/fluent-builder": "^2.1.2",
@@ -56,6 +57,7 @@
"@types/node": "^24.2.1",
"@typescript-eslint/eslint-plugin": "^8.29.0",
"@typescript-eslint/parser": "^8.29.0",
"dependency-cruiser": "^18.2.0",
"esbuild": "^0.25.10",
"esbuild-runner": "^2.2.2",
"eslint": "^9.39.1",
@@ -66,7 +68,6 @@
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.5.0",
"madge": "^8.0.0",
"patch-package": "^8.0.0",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
+1 -1
View File
@@ -2,7 +2,7 @@
"private": true,
"name": "insomnia-analytics",
"license": "Apache-2.0",
"version": "13.2.0",
"version": "13.3.0",
"author": "Kong <office@konghq.com>",
"description": "Shared analytics client for the Insomnia desktop app and CLI",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"private": true,
"name": "insomnia-api",
"license": "Apache-2.0",
"version": "13.2.0",
"version": "13.3.0",
"author": "Kong <office@konghq.com>",
"description": "Insomnia API functionalities",
"repository": {
@@ -59,7 +59,6 @@ export interface FeatureList {
aiMockServers: FeatureStatus;
aiCommitMessages: FeatureStatus;
aiMcpClient: FeatureStatus;
konnectSync: FeatureStatus;
}
export interface Billing {
@@ -182,4 +181,3 @@ export const getOrganizationMemberRoles = ({
sessionId,
});
};
+27 -1
View File
@@ -1,5 +1,8 @@
import type { CurrentUserActionCreate, User, UserEncryptionKeys, UserOnboarding } from '@getinsomnia/insomnia-v3-fetch';
import { CurrentUserActionCreateActionTypeEnum, UserOnboardingFirstRequestTreatmentEnum } from '@getinsomnia/insomnia-v3-fetch';
import {
CurrentUserActionCreateActionTypeEnum,
UserOnboardingFirstRequestTreatmentEnum,
} from '@getinsomnia/insomnia-v3-fetch';
import { fetch } from './fetch';
@@ -102,6 +105,29 @@ export const getUserFiles = async ({ sessionId }: { sessionId: string }) => {
});
};
// GET /v1/user/entitlements
export interface Entitlement {
/** `${featureName}_${entitlementKey}`, e.g. `konnectControlPlanes_default`. */
featureKey: string;
type: 'metered';
hasAccess: boolean;
/** `null` means unlimited. */
allowance: number | null;
}
export const KONNECT_CONTROL_PLANES_FEATURE = 'konnectControlPlanes';
export const KONNECT_CONTROL_PLANES_FEATURE_KEY = `${KONNECT_CONTROL_PLANES_FEATURE}_default`;
/** An account without the feature gets `{ entitlements: [] }` rather than an entry with `hasAccess: false`. */
export const getUserEntitlements = async ({ sessionId, feature }: { sessionId: string; feature?: string }) => {
const query = feature ? `?feature=${encodeURIComponent(feature)}` : '';
return fetch<{ entitlements: Entitlement[] }>({
method: 'GET',
path: `/v1/user/entitlements${query}`,
sessionId,
});
};
// GET learning feature
export interface LearningFeature {
active: boolean;
+7 -2
View File
@@ -8,7 +8,7 @@ import { strings } from './strings';
* @IMPORTANT Not using dot, because NeDB prohibits field names to contain dots.
*/
export const keyboardShortcutDescriptions: Record<KeyboardShortcut, string> = {
workspace_showSettings: `Show ${strings.document.singular} / ${strings.collection.singular} Settings`,
workspace_showSettings: `Show ${strings.collection.singular} Settings`,
request_showSettings: 'Show Request Settings',
preferences_showKeyboardShortcuts: 'Show Keyboard Shortcuts',
preferences_showGeneral: 'Show App Preferences',
@@ -18,7 +18,8 @@ export const keyboardShortcutDescriptions: Record<KeyboardShortcut, string> = {
request_send: 'Send Request',
request_showOptions: 'Send Request (Options)',
environment_showEditor: 'Show Environment Editor',
environment_showSwitchMenu: 'Switch Environments',
environment_showSwitchMenu: 'Switch Collection Environments',
environment_showSwitchProjectMenu: 'Switch Project Environments',
request_toggleHttpMethodMenu: 'Change HTTP Method',
request_toggleHistory: 'Show Request History',
request_focusUrl: 'Focus URL',
@@ -99,6 +100,10 @@ const defaultRegistry: HotKeyRegistry = {
macKeys: [{ shift: true, meta: true, keyCode: keyboardKeys.e.keyCode }],
winLinuxKeys: [{ ctrl: true, shift: true, keyCode: keyboardKeys.e.keyCode }],
},
environment_showSwitchProjectMenu: {
macKeys: [{ alt: true, meta: true, keyCode: keyboardKeys.e.keyCode }],
winLinuxKeys: [{ ctrl: true, alt: true, keyCode: keyboardKeys.e.keyCode }],
},
request_toggleHttpMethodMenu: {
macKeys: [{ shift: true, meta: true, keyCode: keyboardKeys.l.keyCode }],
winLinuxKeys: [{ ctrl: true, shift: true, keyCode: keyboardKeys.l.keyCode }],
@@ -40,6 +40,7 @@ export type KeyboardShortcut =
| 'request_showOptions'
| 'environment_showEditor'
| 'environment_showSwitchMenu'
| 'environment_showSwitchProjectMenu'
| 'request_toggleHttpMethodMenu'
| 'request_toggleHistory'
| 'request_focusUrl'
@@ -204,4 +205,6 @@ export interface Settings {
disabledBlockedRoots: string[];
/** Custom npm registry URL for plugin installation (e.g., corporate mirror). Empty string uses the default https://registry.npmjs.org/. */
npmRegistryUrl: string;
// Forces the test tab to show. Otherwise, only show it if the collection already has legacy tests.
enableLegacyUnitTests: boolean;
}
+4 -4
View File
@@ -18,16 +18,16 @@ type StringId =
export const strings: Record<StringId, StringInfo> = {
collection: {
singular: 'Collection',
plural: 'Collections',
singular: 'API Collection',
plural: 'API Collections',
},
mock: {
singular: 'Mock',
plural: 'Mocks',
},
document: {
singular: 'Document',
plural: 'Documents',
singular: 'API Collection',
plural: 'API Collections',
},
organization: {
singular: 'Organization',
@@ -441,11 +441,13 @@ describe('_repairDatabase()', async () => {
id: '1',
key: 'foo',
value: '1',
source: 'manual',
},
{
id: 'j1_1',
key: 'j1',
value: '1',
source: 'manual',
},
],
},
@@ -457,11 +459,13 @@ describe('_repairDatabase()', async () => {
id: '1',
key: 'foo',
value: '2',
source: 'manual',
},
{
id: 'j2_1',
key: 'j2',
value: '2',
source: 'manual',
},
],
},
@@ -488,16 +492,19 @@ describe('_repairDatabase()', async () => {
id: '1',
key: 'foo',
value: '1',
source: 'manual',
},
{
id: 'j1_1',
key: 'j1',
value: '1',
source: 'manual',
},
{
id: 'j2_1',
key: 'j2',
value: '2',
source: 'manual',
},
],
},
@@ -529,7 +536,7 @@ describe('_repairDatabase()', async () => {
});
// Make sure we have everything
expect((await services.apiSpec.getByParentId(w1._id))?.fileName).toBe('');
expect((await services.apiSpec.getByParentId(w2._id))?.fileName).toBe('New Document');
expect((await services.apiSpec.getByParentId(w2._id))?.fileName).toBe('New API Collection');
expect((await services.apiSpec.getByParentId(w3._id))?.fileName).toBe('Unique name');
// Run the fix algorithm
await repairDatabase();
@@ -0,0 +1,48 @@
import type { CookieJar } from 'insomnia-data';
import { describe, expect, it } from 'vitest';
import { migrate } from './cookie-jar';
function makeCookieJar(cookies: Partial<CookieJar['cookies'][number]>[]): CookieJar {
return { cookies } as CookieJar;
}
describe('migrateCookieSource()', () => {
it('grandfathers a pre-existing cookie with no source field in as manual', () => {
const jar = makeCookieJar([{ id: 'c1', key: 'legacy', value: '{{ _.base_url }}' }]);
const result = migrate(jar);
expect(result.cookies[0].source).toBe('manual');
});
it('leaves a cookie that already has a source untouched', () => {
const jar = makeCookieJar([{ id: 'c1', key: 'fromServer', source: 'response' }]);
const result = migrate(jar);
expect(result.cookies[0].source).toBe('response');
});
it('is idempotent across repeated migrations', () => {
const jar = makeCookieJar([{ id: 'c1', key: 'legacy' }]);
const once = migrate(jar);
const twice = migrate(once);
expect(twice.cookies[0].source).toBe('manual');
});
});
describe('migrateCookieId()', () => {
it('assigns an id to a cookie missing one', () => {
const jar = makeCookieJar([{ key: 'noId' }]);
const result = migrate(jar);
expect(result.cookies[0].id).toBeTruthy();
});
it('keeps an existing id unchanged', () => {
const jar = makeCookieJar([{ id: 'keep-me', key: 'hasId' }]);
const result = migrate(jar);
expect(result.cookies[0].id).toBe('keep-me');
});
});
@@ -12,9 +12,26 @@ function migrateCookieId(cookieJar: CookieJar) {
return cookieJar;
}
/**
* Grandfather in cookies that predate the `source` field as 'manual', so cookies a user
* already relied on for template rendering keep working. Every write path introduced
* alongside `source` (network responses, imports, scripts) always sets it explicitly, so once
* a cookie has passed through this migration once, `source` should never go missing again.
*/
function migrateCookieSource(cookieJar: CookieJar) {
for (const cookie of cookieJar.cookies) {
if (!cookie.source) {
cookie.source = 'manual';
}
}
return cookieJar;
}
export function migrate(doc: CookieJar) {
try {
doc = migrateCookieId(doc);
doc = migrateCookieSource(doc);
return doc;
} catch (e) {
console.log('[db] Error during cookie jar migration', e);
@@ -150,10 +150,14 @@ export async function syncTeamProjects({
}
export async function syncProjects(organizationId: string) {
// Local-only organizations have no team projects to fetch, so bail out before the request.
if (models.organization.isLocalOrganizationId(organizationId)) {
return;
}
const user = await userSessionService.get();
const teamProjects = await getAllTeamProjects(organizationId);
// ensure we don't sync projects in the wrong place
if (Array.isArray(teamProjects) && user.id && !models.organization.isScratchpadOrganizationId(organizationId)) {
if (Array.isArray(teamProjects) && user.id) {
await syncTeamProjects({ teamProjects, organizationId });
}
}
@@ -1,4 +1,4 @@
import type { UnitTestSuite } from 'insomnia-data';
import type { Query, UnitTestSuite } from 'insomnia-data';
import { database as db, models } from 'insomnia-data';
const { type } = models.unitTestSuite;
@@ -32,3 +32,7 @@ export const getById = (_id: string) => db.findOne<UnitTestSuite>(type, { _id })
export function all() {
return db.find<UnitTestSuite>(type);
}
export function count(query?: Query<UnitTestSuite>) {
return db.count(type, query);
}
+3 -2
View File
@@ -2,7 +2,7 @@
"private": true,
"name": "insomnia-data",
"license": "Apache-2.0",
"version": "13.2.0",
"version": "13.3.0",
"author": "Kong <office@konghq.com>",
"description": "Insomnia data functionalities",
"repository": {
@@ -39,7 +39,8 @@
"deep-equal": "2.2.3",
"fuzzysort": "^1.9.0",
"graphql": "^16.10.0",
"uuid": "^11.1.1"
"uuid": "^11.1.1",
"zod": "^3.25.75"
},
"devDependencies": {
"@getinsomnia/node-libcurl": "3.3.0",
@@ -27,6 +27,8 @@ export interface SpecificQuery {
$in?: (string | null)[];
$nin?: string[];
$ne?: string | null;
// NeDB's `$ne` also matches documents where the field is absent, so optional keys need this too.
$exists?: boolean;
}
export type Query<T extends BaseModel = BaseModel> = {
@@ -0,0 +1,23 @@
import { z } from 'zod/v4';
import { type AllTypes, allTypesSchema, baseModelSchema } from './base-types';
export function createModelSchema<TType extends AllTypes, TPrefix extends string>(type: TType, _prefix: TPrefix) {
return baseModelSchema.extend({
type: allTypesSchema.extract([type]),
isPrivate: baseModelSchema.shape.isPrivate.optional().default(false),
name: baseModelSchema.shape.name.optional().default(''),
});
}
// Basic literal types that can appear in JSON data
export const LiteralSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
export const KeyLiteralSchema = z.union([z.string(), z.number()]);
type Literal = z.infer<typeof LiteralSchema>;
type Json = Literal | { [key: string]: Json } | Json[];
// Recursive JSON schema that can handle nested objects and arrays
export const JsonSchema: z.ZodType<Json> = z.lazy(() =>
z.union([LiteralSchema, z.array(JsonSchema), z.record(KeyLiteralSchema, JsonSchema)]),
);
+61 -54
View File
@@ -1,58 +1,65 @@
export type AllTypes =
| 'ApiSpec'
| 'CaCertificate'
| 'ClientCertificate'
| 'CloudCredential'
| 'CookieJar'
| 'Environment'
| 'GitCredentials'
| 'GitRepository'
| 'GrpcRequest'
| 'GrpcRequestMeta'
| 'MockRoute'
| 'MockServer'
| 'OAuth2Token'
| 'PluginData'
| 'Project'
| 'ProjectLintRuleset'
| 'ProtoDirectory'
| 'ProtoFile'
| 'Request'
| 'RequestGroup'
| 'RequestGroupMeta'
| 'RequestMeta'
| 'RequestVersion'
| 'Response'
| 'RunnerTestResult'
| 'Settings'
| 'SocketIOPayload'
| 'SocketIORequest'
| 'SocketIOResponse'
| 'SocketIORequestMeta'
| 'Stats'
| 'UnitTest'
| 'UnitTestResult'
| 'UnitTestSuite'
| 'UserSession'
| 'WebSocketPayload'
| 'WebSocketRequest'
| 'WebSocketResponse'
| 'WebSocketRequestMeta'
| 'McpRequest'
| 'McpResponse'
| 'McpPayload'
| 'Workspace'
| 'WorkspaceMeta';
import { z } from 'zod/v4';
export interface BaseModel {
_id: string;
type: AllTypes;
export const allTypesSchema = z.enum([
'ApiSpec',
'CaCertificate',
'ClientCertificate',
'CloudCredential',
'CookieJar',
'Environment',
'GitCredentials',
'GitRepository',
'GrpcRequest',
'GrpcRequestMeta',
'MockRoute',
'MockServer',
'OAuth2Token',
'PluginData',
'Project',
'ProjectLintRuleset',
'ProtoDirectory',
'ProtoFile',
'Request',
'RequestGroup',
'RequestGroupMeta',
'RequestMeta',
'RequestVersion',
'Response',
'RunnerTestResult',
'Settings',
'SocketIOPayload',
'SocketIORequest',
'SocketIOResponse',
'SocketIORequestMeta',
'Stats',
'UnitTest',
'UnitTestResult',
'UnitTestSuite',
'UserSession',
'WebSocketPayload',
'WebSocketRequest',
'WebSocketResponse',
'WebSocketRequestMeta',
'McpRequest',
'McpResponse',
'McpPayload',
'Workspace',
'WorkspaceMeta',
]);
export type AllTypes = z.infer<typeof allTypesSchema>;
export const baseModelSchema = z.object({
_id: z.string(),
type: allTypesSchema,
// TSCONVERSION -- parentId is always required for all models, except 4:
// - Stats, Settings, and Project, which never have a parentId
// - Workspace optionally has a parentId (which will be the id of a Project)
parentId: string; // or null
modified: number;
created: number;
isPrivate: boolean;
name: string;
}
parentId: z.string(),
modified: z.number(),
created: z.number(),
isPrivate: z.boolean(),
name: z.string(),
});
export type BaseModel = z.infer<typeof baseModelSchema>;
@@ -25,6 +25,10 @@ export interface Cookie {
hostOnly?: boolean;
pathIsDefault?: boolean;
lastAccessed?: Date;
// 'manual' cookies (added/edited via the Cookie Jar UI, or written by a script) may contain
// template syntax that gets rendered; 'response' cookies came from a server's Set-Cookie
// header (or an import) and are always rendered as plain literal text.
source?: 'manual' | 'response';
}
export interface BaseCookieJar {
@@ -1,3 +1,6 @@
import { z } from 'zod/v4';
import { createModelSchema } from './base-schemas';
import type { BaseModel } from './base-types';
export const name = 'Environment';
@@ -14,18 +17,6 @@ export const canSync = true;
// for those keys do not need to add in model init method
export const optionalKeys: (keyof BaseEnvironment)[] = ['kvPairData', 'environmentType', 'dataPropertyOrder'];
export interface BaseEnvironment {
name: string;
data: Record<string, any>;
dataPropertyOrder?: Record<string, any> | null;
kvPairData?: EnvironmentKvPairData[];
color: string | null;
metaSortKey: number;
// For sync control
isPrivate: boolean;
environmentType?: EnvironmentType;
}
export enum EnvironmentType {
JSON = 'json',
KVPAIR = 'kv',
@@ -37,15 +28,32 @@ export enum EnvironmentKvPairDataType {
SECRET = 'secret',
}
export interface EnvironmentKvPairData {
id: string;
name: string;
value: string;
type: EnvironmentKvPairDataType;
enabled?: boolean;
}
export const EnvironmentKvPairDataSchema = z.object({
id: z.string(),
name: z.string().optional().default(''),
value: z.string().optional().default(''),
type: z.enum([EnvironmentKvPairDataType.JSON, EnvironmentKvPairDataType.STRING, EnvironmentKvPairDataType.SECRET]),
enabled: z.boolean().optional(),
});
export type EnvironmentKvPairData = z.infer<typeof EnvironmentKvPairDataSchema>;
export type Environment = BaseModel & BaseEnvironment;
export const environmentTypeSchema = z.enum([EnvironmentType.JSON, EnvironmentType.KVPAIR]);
export const baseEnvironmentSchema = z.object({
name: z.string().optional().default(''),
data: z.record(z.string(), z.any()).optional().default({}),
color: z.string().optional().nullable(),
dataPropertyOrder: z.record(z.string(), z.any()).nullable().optional(),
kvPairData: z.array(EnvironmentKvPairDataSchema).optional(),
metaSortKey: z
.number()
.optional()
.default(() => Date.now()),
environmentType: environmentTypeSchema.optional(),
});
export type BaseEnvironment = z.infer<typeof baseEnvironmentSchema>;
export const schema = createModelSchema(type, prefix).extend(baseEnvironmentSchema.shape);
export type Environment = z.infer<typeof schema>;
// This is a representation of the data taken from a csv or json file AKA iterationData
export type UserUploadEnvironment = Pick<Environment, 'data' | 'dataPropertyOrder' | 'name'>;
@@ -1,8 +1,35 @@
import type { PersonalPlanType } from 'insomnia-api';
import type { Organization, PersonalPlanType } from 'insomnia-api';
export const SCRATCHPAD_ORGANIZATION_ID = 'org_scratchpad';
export const isScratchpadOrganizationId = (organizationId: string) => organizationId === SCRATCHPAD_ORGANIZATION_ID;
export const KONNECT_ORGANIZATION_ID_PREFIX = 'org_konnect_';
export const KONNECT_ORGANIZATION_NAME = 'Control Planes';
// The Konnect organization is local-only, but the database is not partitioned per user, so the
// account id is baked into the id to keep one account's Konnect data out of another's.
export const getKonnectOrganizationId = (accountId: string) => `${KONNECT_ORGANIZATION_ID_PREFIX}${accountId}`;
export const isKonnectOrganizationId = (organizationId: string) =>
organizationId.startsWith(KONNECT_ORGANIZATION_ID_PREFIX);
/** Organizations that exist only on this machine and must never be used for organization-scoped API calls. */
export const isLocalOrganizationId = (organizationId: string) =>
isScratchpadOrganizationId(organizationId) || isKonnectOrganizationId(organizationId);
export const buildKonnectOrganization = (accountId: string): Organization => ({
id: getKonnectOrganizationId(accountId),
name: KONNECT_ORGANIZATION_NAME,
picture: null,
owner_first_name: null,
owner_last_name: null,
owner_email: null,
total_members: 1,
total_invites: 0,
total_admins: 1,
is_owner: true,
can_leave: false,
});
export const formatCurrentPlanType = (type: PersonalPlanType) => {
switch (type) {
case 'free': {
@@ -22,6 +22,8 @@ export interface BaseRequestMeta {
downloadPath: string | null;
expandedAccordionKeys: Partial<Record<RequestAccordionKeys, boolean>>;
activeMcpPrimitive?: string | null;
streamSummaryPath: string | null;
streamSummaryRenderMarkdown: boolean;
}
export type RequestMeta = BaseModel & BaseRequestMeta;
@@ -41,5 +43,7 @@ export function init() {
downloadPath: null,
expandedAccordionKeys: {},
activeMcpPrimitive: null,
streamSummaryPath: null,
streamSummaryRenderMarkdown: false,
};
}
@@ -94,5 +94,6 @@ export function init(): BaseSettings {
disabledBlockedProperties: [],
disabledBlockedRoots: [],
npmRegistryUrl: '',
enableLegacyUnitTests: false,
};
}
@@ -62,7 +62,8 @@ export const scopeToActivity = (scope: WorkspaceScope) => {
return 'debug';
}
case WorkspaceScopeKeys.design: {
return 'spec';
// Legacy spec workspace route has been merged into the debug route, so we return debug here as well.
return 'debug';
}
case WorkspaceScopeKeys.mockServer: {
return 'mock-server';
+6 -4
View File
@@ -1,7 +1,7 @@
{
"private": true,
"name": "insomnia-inso",
"version": "13.2.0",
"version": "13.3.0",
"homepage": "https://insomnia.rest",
"description": "A CLI for Insomnia - The Collaborative API Design Tool",
"author": "Kong <office@konghq.com>",
@@ -45,6 +45,7 @@
"artifacts": "esr src/scripts/artifacts.ts"
},
"devDependencies": {
"@types/papaparse": "^5.3.15",
"cross-env": "^10.1.0",
"esbuild": "^0.25.10",
"esbuild-runner": "^2.2.2",
@@ -59,15 +60,16 @@
"@stoplight/types": "^14.1.1",
"commander": "^12.1.0",
"consola": "^3.4.2",
"fast-string-width": "^3.0.2",
"is-unicode-supported": "^2.1.0",
"cosmiconfig": "^9.0.0",
"enquirer": "^2.4.1",
"fast-string-width": "^3.0.2",
"is-unicode-supported": "^2.1.0",
"papaparse": "^5.5.2",
"picocolors": "^1.1.1",
"string-argv": "^0.3.2",
"yaml": "^2.7.1"
},
"optionalDependencies": {
"@kong/insomnia-plugin-external-vault": "0.1.5-dev.20260806050036"
"@kong/insomnia-plugin-external-vault": "0.1.5"
}
}
+4 -7
View File
@@ -29,6 +29,7 @@ import { servicesNodeImpl } from 'insomnia-data/node';
import { generate } from 'insomnia-testing/src/generate/generate';
import { runTestsCli } from 'insomnia-testing/src/run/run';
import orderedJSON from 'json-order';
import Papa from 'papaparse';
import { parseArgsStringToArgv } from 'string-argv';
import { v4 as uuidv4 } from 'uuid';
@@ -215,7 +216,7 @@ const pathToIterationData = async (pathOrUrl: string, env: string[]): Promise<Us
const list = getListFromFileOrUrl(content, fileType).map(data => ({ ...data, ...envAsObject }));
return transformIterationDataToEnvironmentList(list);
};
const getListFromFileOrUrl = (content: string, fileType?: string): Record<string, string>[] => {
export const getListFromFileOrUrl = (content: string, fileType?: string): Record<string, string>[] => {
if (fileType === 'json') {
try {
const jsonDataContent = JSON.parse(content);
@@ -229,12 +230,8 @@ const getListFromFileOrUrl = (content: string, fileType?: string): Record<string
throw new Error('Upload JSON file can not be parsed');
}
} else if (fileType === 'csv') {
// Replace CRLF (Windows line break) and CR (Mac link break) with \n, then split into csv arrays
const csvRows = content
.replace(/\r\n|\r/g, '\n')
.split('\n')
.map(row => row.split(','));
// at least 2 rows required for csv
const csvRows = Papa.parse<string[]>(content, { skipEmptyLines: true, delimiter: ',' }).data;
// at least 2 rows required for csv, first row as variable names
if (csvRows.length > 1) {
const csvHeaders = csvRows[0];
const csvContentRows = csvRows.slice(1);
@@ -1,6 +1,9 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import YAML from 'yaml';
import gitAdapter from './git-adapter';
@@ -36,4 +39,104 @@ describe('gitAdapter()', () => {
const db = await gitAdapter(workingDir);
expect(db).toBe(null);
});
describe('type restriction', () => {
let workingDir: string;
afterEach(() => {
if (workingDir) {
fs.rmSync(workingDir, { recursive: true, force: true });
}
});
it('does not read a Settings folder, since Settings.canSync is false', async () => {
// gitAdapter() only reads folders for types where models.canSync() is
// true, so a 'Settings' folder is never opened even when it sits
// alongside a normal Workspace folder with well-formed content.
workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-adapter-'));
const insomniaDir = path.join(workingDir, '.insomnia');
fs.mkdirSync(path.join(insomniaDir, 'Workspace'), { recursive: true });
fs.writeFileSync(
path.join(insomniaDir, 'Workspace', 'wrk_new.yml'),
YAML.stringify({
_id: 'wrk_new',
type: 'Workspace',
name: 'New workspace',
parentId: null,
scope: 'collection',
}),
);
fs.mkdirSync(path.join(insomniaDir, 'Settings'), { recursive: true });
const settingsData = {
_id: 'set_new',
type: 'Settings',
validateSSL: false,
proxyEnabled: true,
httpProxy: 'http://example.com:8080',
httpsProxy: 'http://example.com:8080',
};
fs.writeFileSync(
path.join(insomniaDir, 'Settings', 'set_new.yml'),
YAML.stringify(settingsData),
);
const db = await gitAdapter(workingDir);
expect(db?.Settings).toHaveLength(0);
expect(db?.Workspace).toHaveLength(1);
});
// gitAdapter() only ever reads folders named after one of the Database
// keys (see packages/insomnia-inso/src/db/types.ts), so this is the full
// set of syncable types reachable through the inso CLI import path today.
const syncableTypes = ['ApiSpec', 'Environment', 'Request', 'RequestGroup', 'Workspace', 'UnitTestSuite', 'UnitTest'] as const;
// Non-syncable Database keys: these folders exist in legacy .insomnia
// directories but must never be merged in, since canSync is false.
const nonSyncableTypes = ['WorkspaceMeta', 'ClientCertificate', 'CaCertificate', 'CookieJar', 'CloudCredential', 'Settings'] as const;
const writeMinimalDoc = (insomniaDir: string, type: string) => {
const typeDir = path.join(insomniaDir, type);
fs.mkdirSync(typeDir, { recursive: true });
const doc = { _id: `${type.toLowerCase()}_new`, type, name: `New ${type}`, parentId: null };
fs.writeFileSync(path.join(typeDir, `${doc._id}.yml`), YAML.stringify(doc));
};
it.each(syncableTypes)('reads a %s folder, since canSync is true', async type => {
workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-adapter-'));
const insomniaDir = path.join(workingDir, '.insomnia');
writeMinimalDoc(insomniaDir, type);
const db = await gitAdapter(workingDir);
expect(db?.[type as keyof typeof db]).toHaveLength(1);
});
it.each(nonSyncableTypes)('does not read a %s folder, since canSync is false', async type => {
workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-adapter-'));
const insomniaDir = path.join(workingDir, '.insomnia');
writeMinimalDoc(insomniaDir, type);
// Include a syncable folder alongside it so we confirm the whole read didn't just fail
writeMinimalDoc(insomniaDir, 'Workspace');
const db = await gitAdapter(workingDir);
expect(db?.[type as keyof typeof db]).toHaveLength(0);
expect(db?.Workspace).toHaveLength(1);
});
it.each(nonSyncableTypes)('ignores a document whose own type is %s even when filed under a syncable folder', async otherType => {
workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-adapter-'));
const insomniaDir = path.join(workingDir, '.insomnia');
const typeDir = path.join(insomniaDir, 'Workspace');
fs.mkdirSync(typeDir, { recursive: true });
const doc = { _id: 'wrk_mismatched', type: otherType, name: 'Mismatched doc', parentId: null };
fs.writeFileSync(path.join(typeDir, `${doc._id}.yml`), YAML.stringify(doc));
const db = await gitAdapter(workingDir);
expect(db?.Workspace).toHaveLength(0);
});
});
});
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { models } from 'insomnia-data';
import YAML from 'yaml';
import type { Database, DbAdapter } from '../types';
@@ -35,10 +36,20 @@ const gitAdapter: DbAdapter = async (dir, filterTypes) => {
return;
}
const obj = YAML.parse(contents);
// Document's own type must match the folder it was read from.
if (obj?.type !== type) {
console.error(`Ignoring "${fileName}": document type "${obj?.type}" does not match folder type "${type}"`);
return;
}
(db[type] as {}[]).push(obj);
};
const types = filterTypes?.length ? filterTypes : (Object.keys(db) as (keyof Database)[]);
// Only ever read folders for types explicitly marked as syncable. A folder
// named after a non-syncable/global-singleton type (e.g. Settings) is skipped.
const requestedTypes = filterTypes?.length ? filterTypes : (Object.keys(db) as (keyof Database)[]);
const types = requestedTypes.filter(t => models.getModel(t)?.canSync);
await Promise.all(
types.map(async t => {
// Get all files in type dir
@@ -127,7 +127,7 @@ const insomniaAdapter: DbAdapter = async (filePath, filterTypes) => {
const obj = parseRaw(model);
// Store it, only if the key value exists
(db[obj.type] as {}[])?.push(obj);
(db[obj.type as keyof typeof db] as {}[])?.push(obj);
}
});
@@ -1,10 +1,10 @@
// Simplified and extracted from insomnia/src/models/*
import type { Database } from '../types';
import type { AllTypes } from 'insomnia-data';
export interface BaseModel {
_id: string;
name: string;
type: keyof Database;
type: AllTypes;
parentId: string;
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { getListFromFileOrUrl } from './cli';
describe('getListFromFileOrUrl()', () => {
it('parses simple csv', () => {
expect(getListFromFileOrUrl('a,b\n1,2\n3,4', 'csv')).toEqual([
{ a: '1', b: '2' },
{ a: '3', b: '4' },
]);
});
it('keeps commas inside quoted fields', () => {
expect(getListFromFileOrUrl('origin,destination\n"New York, NY","Boston, MA"\n', 'csv')).toEqual([
{ origin: 'New York, NY', destination: 'Boston, MA' },
]);
});
it('unescapes doubled quotes inside quoted fields', () => {
expect(getListFromFileOrUrl('name,quote\n"John ""JJ"" Smith",hello', 'csv')).toEqual([
{ name: 'John "JJ" Smith', quote: 'hello' },
]);
});
it('handles CRLF line breaks', () => {
expect(getListFromFileOrUrl('a,b\r\n1,2\r\n', 'csv')).toEqual([{ a: '1', b: '2' }]);
});
it('handles newlines inside quoted fields', () => {
expect(getListFromFileOrUrl('a,b\n"line1\nline2",2', 'csv')).toEqual([{ a: 'line1\nline2', b: '2' }]);
});
it('defaults missing trailing cells to empty string', () => {
expect(getListFromFileOrUrl('a,b,c\n1,2', 'csv')).toEqual([{ a: '1', b: '2', c: '' }]);
});
it('throws when there are fewer than two rows', () => {
expect(() => getListFromFileOrUrl('a,b', 'csv')).toThrow(
'CSV file must contain at least two rows with first row as variable names',
);
});
it('throws for unsupported file types', () => {
expect(() => getListFromFileOrUrl('anything', 'yaml')).toThrow('Uploaded file is unsupported yaml');
});
});
@@ -1,7 +1,7 @@
{
"private": true,
"name": "insomnia-scripting-environment",
"version": "13.2.0",
"version": "13.3.0",
"description": "",
"main": "src/objects/index.ts",
"types": "src/objects/index.ts",
@@ -7,7 +7,7 @@ import { Folder, ParentFolders } from '../folders';
import { Url } from '../urls';
describe('test Variables object', () => {
it('test basic operations', async () => {
it('test basic operations', () => {
const variables = new Variables({
baseGlobalVars: new Environment('baseGlobals', { value: 'baseValue' }),
globalVars: new Environment('globals', { value: 'xyz' }),
@@ -18,17 +18,17 @@ describe('test Variables object', () => {
localVars: new Environment('local', {}),
});
const uuidAndXyz = await variables.replaceIn('{{ $randomUUID }}{{value }}');
const uuidAndXyz = variables.replaceIn('{{ $randomUUID }}{{value }}');
expect(validate(uuidAndXyz.replace('xyz', ''))).toBeTruthy();
const uuidAndBrackets1 = await variables.replaceIn('{{ $randomUUID }}}}');
const uuidAndBrackets1 = variables.replaceIn('{{ $randomUUID }}}}');
expect(validate(uuidAndBrackets1.replace('}}', ''))).toBeTruthy();
const uuidAndBrackets2 = await variables.replaceIn('}}{{ $randomUUID }}');
const uuidAndBrackets2 = variables.replaceIn('}}{{ $randomUUID }}');
expect(validate(uuidAndBrackets2.replace('}}', ''))).toBeTruthy();
});
it('test environment overriding', async () => {
it('test environment overriding', () => {
const baseGlobalVariables = new Variables({
baseGlobalVars: new Environment('baseGlobals', { scope: 'baseGlobals', value: 'baseGlobals-value' }),
globalVars: new Environment('globals', {}),
@@ -94,10 +94,10 @@ describe('test Variables object', () => {
expect(variablesWithFolderLevelData.get('value')).toEqual('folderLevel2-value');
expect(variablesWithLocalData.get('value')).toEqual('local-value');
expect(await variablesWithFolderLevelData.replaceIn('{{ value}}')).toEqual('folderLevel2-value');
expect(variablesWithFolderLevelData.replaceIn('{{ value}}')).toEqual('folderLevel2-value');
const urlObj = new Url('http://x/{{ value }}');
expect(await variablesWithFolderLevelData.replaceIn(urlObj)).toEqual('http://x/folderLevel2-value');
expect(variablesWithFolderLevelData.replaceIn(urlObj)).toEqual('http://x/folderLevel2-value');
});
it('variables operations', () => {
@@ -14,7 +14,7 @@ describe('test Property objects', () => {
});
});
it('Property: basic operations', async () => {
it('Property: basic operations', () => {
const prop = new Property('id', 'name', false, { id: 'real_id', name: 'real_name' });
expect(prop.toJSON()).toEqual({
@@ -23,9 +23,9 @@ describe('test Property objects', () => {
name: 'real_name',
});
expect(await Property.replaceSubstitutions('{{ hehe }}', { hehe: 777 })).toEqual('777');
expect(Property.replaceSubstitutions('{{ hehe }}', { hehe: 777 })).toEqual('777');
expect(
await Property.replaceSubstitutionsIn(
Property.replaceSubstitutionsIn(
{
value: '{{ hehe }}',
},
@@ -124,7 +124,7 @@ export class Environment {
*
* @throws Will throw an error if template is not a string or object.
*/
replaceIn = async (template: string | object) => {
replaceIn = (template: string | object) => {
if (typeof template === 'object') {
template = template.toString();
} else if (typeof template !== 'string') {
@@ -327,7 +327,7 @@ export class Variables {
*
* @throws Will throw an error if template is not a string or object.
*/
replaceIn = async (template: string | object) => {
replaceIn = (template: string | object) => {
if (typeof template === 'object') {
template = template.toString();
} else if (typeof template !== 'string') {
@@ -17,9 +17,9 @@ class Interpolator {
});
}
render = async (template: string, context: object): Promise<string> => {
render = (template: string, context: object): string => {
// TODO: support plugins
return this.engine.parseAndRender(this.renderWithFaker(template), context);
return this.engine.parseAndRenderSync(this.renderWithFaker(template), context);
};
renderWithFaker = (template: string) => {
@@ -147,7 +147,7 @@ export class Property extends PropertyBase {
static _index = 'id';
static async replaceSubstitutions(content: string, ...variables: object[]): Promise<string> {
static replaceSubstitutions(content: string, ...variables: object[]): string {
if (!Array.isArray(variables) || typeof content !== 'string') {
throw new TypeError(
"replaceSubstitutions: the first param's type is not string or other parameters are not an array",
@@ -161,7 +161,7 @@ export class Property extends PropertyBase {
return getInterpolator().render(content, context);
}
static async replaceSubstitutionsIn(obj: object, ...variables: object[]): Promise<object> {
static replaceSubstitutionsIn(obj: object, ...variables: object[]): object {
if (!Array.isArray(variables) || typeof obj !== 'object') {
throw new TypeError(
"replaceSubstitutions: the first param's type is not object or other parameters are not an array",
@@ -177,7 +177,7 @@ export class Property extends PropertyBase {
context = { ...context, ...variable };
});
const rendered = await getInterpolator().render(content, context);
const rendered = getInterpolator().render(content, context);
return JSON.parse(rendered);
} catch (e: any) {
throw new Error(`replaceSubstitutionsIn: ${e.toString()}`);
+3
View File
@@ -20,3 +20,6 @@ fixtures/inso-nedb/insomnia.OAuth2Token.db
fixtures/inso-nedb/insomnia.PluginData.db
fixtures/inso-nedb/insomnia.CaCertificate.db
fixtures/inso-nedb/insomnia.CloudCredential.db
# Playwright junit/xml run artifacts
test-results.xml
+1 -1
View File
@@ -11,7 +11,7 @@
"bugs": {
"url": "https://github.com/kong/insomnia/issues"
},
"version": "13.2.0",
"version": "13.3.0",
"scripts": {
"test:dev": "xvfb-maybe cross-env BUNDLE=dev playwright test",
"test:build": "xvfb-maybe cross-env BUNDLE=build playwright test",
@@ -33,8 +33,11 @@ const viteServer: PlaywrightTestConfig['webServer'] = {
stdout: 'pipe',
stderr: 'pipe',
wait: {
stdout: /VITE\s+ready in/,
stdout: /VITE.+ready in/,
},
// Without this, Playwright SIGKILLs the process group on teardown, which the
// .vite-port cleanup in vite.config.ts can't intercept, leaving a stale port file.
gracefulShutdown: isWindows ? undefined : { signal: 'SIGTERM', timeout: 2000 },
};
const onlyStartWebServerInDev = !process.env.BUNDLE || process.env.BUNDLE === 'dev';
const config: PlaywrightTestConfig = {
@@ -11,7 +11,7 @@ export class ExportModal {
* Handles the export type selection modal (Insomnia v5 or HAR).
* @param format - The format to select ('yaml' for Insomnia v5, 'har' for HAR)
*/
async selectExportFormat(format: 'yaml' | 'har'): Promise<void> {
async selectExportFormat(format: 'yaml' | 'har' | 'json'): Promise<void> {
await this.page.getByText('Which format would you like to export as?').waitFor({ state: 'visible' });
// The modal uses a <select> element, so we need to use selectOption
@@ -15,15 +15,16 @@ export class NavigationSidebar {
}
// ===========================================================================
// Tab controls
// Organization switching
// ===========================================================================
async clickProjectsTab(): Promise<void> {
await this.root.getByTestId('sidebar-tab-projects').click();
async selectOrganization(name: string): Promise<void> {
await this.page.getByRole('button', { name: 'Organizations' }).click();
await this.page.getByRole('option', { name }).click();
}
async clickKonnectTab(): Promise<void> {
await this.root.getByTestId('sidebar-tab-konnect').click();
async openControlPlanesOrganization(): Promise<void> {
await this.selectOrganization('Control Planes');
}
// ===========================================================================
@@ -94,10 +95,15 @@ export class NavigationSidebar {
return this.navigationTree.getByRole('row', { name: workspaceName });
}
// When collection focus mode narrows the sidebar to one collection, that collection's tree
// row is replaced by the back-arrow header (same `workspace-node-*` testid, no ARIA "row").
async isWorkspaceFocused(workspaceName: string): Promise<boolean> {
return (await this.workspaceRow(workspaceName).getByLabel('Back to all projects').count()) > 0;
// Focus mode swaps the collection's tree row for a header at the top of the sidebar carrying the
// back arrow and the collection's name — that header is the only title the sidebar shows for the
// active collection. Waiting for it also absorbs the window right after navigating in, where the
// tree is still rendered and the swap hasn't happened yet. Both assertions re-query on every
// retry, so the swap unmounting the tree row can't wedge them, as a single locator query would
// (it keeps waiting for its selector to match again, which never happens once focus mode is in).
async expectWorkspaceActive(workspaceName: string): Promise<void> {
await expect(this.root.getByLabel('Back to all projects')).toBeVisible();
await expect(this.root.getByTestId(`workspace-node-${workspaceName}`)).toContainText(workspaceName);
}
// Exits collection focus mode if currently focused; no-op otherwise. Needed before
@@ -110,30 +116,6 @@ export class NavigationSidebar {
}
}
async expectWorkspaceActive(workspaceName: string): Promise<void> {
await expect.soft(this.workspaceRow(workspaceName)).toBeVisible();
// In focus mode there's no grid row to check aria-selected on for this workspace — being
// shown as the focused header already proves it's the active one. Poll for either outcome
// rather than deciding up front: right after navigating in, there's a brief window where
// neither is true yet (focus mode hasn't finished swapping the row for the header), and a
// one-shot check can catch that transient state and commit to the wrong branch.
await expect
.poll(
async () => {
if (await this.isWorkspaceFocused(workspaceName)) {
return true;
}
const gridItem = this.workspaceGridListItem(workspaceName);
if ((await gridItem.count()) === 0) {
return false;
}
return (await gridItem.getAttribute('aria-selected')) === 'true';
},
{ timeout: 25_000 },
)
.toBe(true);
}
async selectWorkspace(workspaceName: string): Promise<void> {
await this.workspaceRow(workspaceName).click();
}
@@ -163,7 +145,7 @@ export class NavigationSidebar {
workspaceName: string;
}): Promise<void> {
await this.openWorkspaceActionsDropdown(workspaceName);
await this.page.getByRole('menuitemradio', { name: actionName }).click();
await this.page.getByRole('menuitemradio', { name: actionName, exact: true }).click();
}
async expandWorkspace(workspaceName: string): Promise<void> {
@@ -103,9 +103,9 @@ export class ProjectPage extends BasePage {
}
async createCollection(name = 'My Collection'): Promise<void> {
await this.selectCreateInProjectType('Collection');
await this.selectCreateInProjectType('API Collection');
await this.page.getByRole('dialog').waitFor({ state: 'visible' });
const nameInput = this.page.getByRole('dialog').getByPlaceholder('Enter a name for your Request Collection');
const nameInput = this.page.getByRole('dialog').getByPlaceholder('Enter a name for your API Collection');
await nameInput.waitFor({ state: 'visible' });
await nameInput.fill(name);
await this.page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
@@ -175,7 +175,10 @@ export class ProjectPage extends BasePage {
// mid-click. Tolerate that here; the waitFor below is the real assertion that the modal is gone.
const discardConfirmDialog = this.page.getByRole('dialog', { name: 'Unsaved changes' });
if (await discardConfirmDialog.isVisible().catch(() => false)) {
await discardConfirmDialog.getByRole('button', { name: 'Yes' }).click({ timeout: 5000 }).catch(() => {});
await discardConfirmDialog
.getByRole('button', { name: 'Yes' })
.click({ timeout: 5000 })
.catch(() => {});
}
await dialog.waitFor({ state: 'hidden' });
@@ -271,7 +274,10 @@ export class ProjectPage extends BasePage {
// mid-click. Tolerate that here; the waitFor below is the real assertion that the modal is gone.
const discardConfirmDialog = this.page.getByRole('dialog', { name: 'Unsaved changes' });
if (await discardConfirmDialog.isVisible().catch(() => false)) {
await discardConfirmDialog.getByRole('button', { name: 'Yes' }).click({ timeout: 5000 }).catch(() => {});
await discardConfirmDialog
.getByRole('button', { name: 'Yes' })
.click({ timeout: 5000 })
.catch(() => {});
}
}
// The modal's backdrop still intercepts clicks for a moment after closing
@@ -357,7 +363,7 @@ export class ProjectPage extends BasePage {
await this.workspaceList.openWorkspaceCardDropdown(workspaceName);
// Click Export option
await this.page.getByRole('menuitem', { name: 'Export' }).click();
await this.page.getByRole('menuitem', { name: 'Export', exact: true }).click();
// Click Export button in the export requests modal (all requests selected by default)
await this.page.getByRole('dialog').getByRole('button', { name: 'Export' }).click();
@@ -54,6 +54,19 @@ app.get('/pets/:id', (req, res) => {
res.status(200).send({ id: req.params.id });
});
// Server-sent events: writes a couple of events and closes, so a test can assert the event-stream
// response pane without holding a connection open for the rest of the run.
app.get('/sse', (_req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: hello-from-sse-1\n\n');
res.write('data: hello-from-sse-2\n\n');
res.end();
});
app.get('/large-json', (_req, res) => {
const items = Array.from({ length: 100_000 }, (_, i) => ({
id: i,
@@ -121,15 +121,25 @@ const defaultOrganizationFeatures = {
bulkImport: {
enabled: true,
},
konnectSync: {
enabled: true,
},
};
let organizationFeatures = {
features: { ...defaultOrganizationFeatures },
};
interface Entitlement {
featureKey: string;
type: 'metered';
hasAccess: boolean;
allowance: number | null;
}
const defaultEntitlements: Entitlement[] = [
{ featureKey: 'konnectControlPlanes_default', type: 'metered', hasAccess: true, allowance: null },
];
let entitlements: Entitlement[] = [...defaultEntitlements];
const v3User = {
id: 'acct_64a477e6b59d43a5a607f84b4f73e3ce',
email: 'insomnia-user@konghq.com',
@@ -520,6 +530,20 @@ export default function setup(app: Application) {
res.status(200).send();
});
// An account without the feature gets an empty list rather than `hasAccess: false`.
app.get('/v1/user/entitlements', (req, res) => {
const feature = typeof req.query.feature === 'string' ? req.query.feature : undefined;
res.status(200).send({
entitlements: feature ? entitlements.filter(e => e.featureKey.startsWith(`${feature}_`)) : entitlements,
});
});
// Test Utility Endpoint - Allows altering entitlements at runtime.
app.post('/v1/test-utils/user/entitlements', json(), (req, res) => {
entitlements = Array.isArray(req.body?.entitlements) ? req.body.entitlements : [...defaultEntitlements];
res.status(200).send();
});
// Projects
app.get('/v1/organizations/:orgId/team-projects', (_req, res) => {
res.status(200).send({
@@ -30,7 +30,7 @@ testWithLegacyDatabase('Run data migration to version 8', async ({ page, userCon
// Open migrated local migrated collection that should have Git Sync
await page.getByLabel('Local Collection').click();
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByLabel('Select a Collection').getByRole('option', { name: 'Mars' }).press('Enter');
await page.locator('body').click();
// The collection is moved to a local project
@@ -6,7 +6,11 @@ import { test } from '../../playwright/test';
test.describe('after-response script features tests', () => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
test('after-response scripts: transient vars, assertions, and environment/global persistence', async ({ page, app, insomnia }) => {
test('after-response scripts: transient vars, assertions, and environment/global persistence', async ({
page,
app,
insomnia,
}) => {
// import global environment
const globalEnvText = await loadFixture('script-global-environment.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), globalEnvText);
@@ -81,8 +85,8 @@ test.describe('after-response script features tests', () => {
await expect.soft(statusTag1).toContainText('200 OK');
// verify persisted environment
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByRole('button', { name: 'Manage collection environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
const responseBody = page.getByRole('dialog').getByTestId('CodeEditor').locator('.CodeMirror-line');
const rows1 = await responseBody.allInnerTexts();
const bodyJson = JSON.parse(rows1.join(' '));
@@ -100,9 +104,7 @@ test.describe('after-response script features tests', () => {
await page.locator('body').click();
await insomnia.navigationSidebar.clickRequestOrFolder('persist global environment');
// activate global sub environment
await page.getByLabel('Manage Environments').click();
await page.getByPlaceholder('Choose a project environment').click();
await page.getByRole('option', { name: 'Script Environment' }).click();
await page.getByLabel('Select a Project Environment').click();
await page.getByRole('option', { name: 'Sub Script Env' }).click();
await page.locator('body').click();
// send
@@ -119,8 +121,9 @@ test.describe('after-response script features tests', () => {
await page.getByText('log: globals sub').click();
await page.getByText('log: baseGlobals base').click();
// view sub environment has been updated
await page.getByLabel('Manage Environments').click();
await page.getByLabel('Manage project environment').click();
await page.getByLabel('Select a Project Environment').click();
await page.getByRole('option', { name: 'Script Environment', exact: true }).hover();
await page.getByLabel('Edit Script Environment').click();
await page.getByLabel('Environment name').getByText('Sub Script Env').first().click();
let globalSubEditor = page.getByTestId('CodeEditor').locator('.CodeMirror-line');
let globalSubRows = await globalSubEditor.allInnerTexts();
@@ -141,7 +144,11 @@ test.describe('after-response script features tests', () => {
});
});
test('insomnia.expect covers each matcher (equal/property/lengthOf/include/within/keys) and test.skip is reported', async ({ page, app, insomnia }) => {
test('insomnia.expect covers each matcher (equal/property/lengthOf/include/within/keys) and test.skip is reported', async ({
page,
app,
insomnia,
}) => {
const text = await loadFixture('after-response-collection.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
@@ -12,14 +12,17 @@ test('can send requests', async ({ page, insomnia }) => {
await insomnia.projectPage.importFixture('smoke-test-collection.yaml');
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('Smoke tests');
await page.getByRole('menuitemradio', { name: 'Export' }).click();
await page.getByRole('menuitemradio', { name: 'Export', exact: true }).click();
await page.getByRole('button', { name: 'Export' }).click();
await page.getByText('Which format would you like to export as?').click();
await insomnia.pressEscape();
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('Smoke tests');
await page.getByRole('menuitemradio', { name: 'From Curl' }).click();
await page.locator('.CodeMirror textarea').fill('curl --request GET --url http://127.0.0.1:4010/echo');
await page
.getByRole('dialog')
.locator('.CodeMirror textarea')
.fill('curl --request GET --url http://127.0.0.1:4010/echo');
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await expect
@@ -160,7 +160,7 @@ test.describe('Cloud Sync', () => {
// delete workspace locally
await page.getByLabel('My Collection R1').getByTestId('DropdownButton').click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
await page.getByText('Remove Local Copy').click();
await page.getByRole('button', { name: 'Delete Workspace' }).click();
// check workspace is deleted locally
@@ -174,10 +174,76 @@ test.describe('Cloud Sync', () => {
// delete workspace both locally and remotely
await page.getByTestId('workspace-grid').getByLabel('My Collection R1').getByTestId('DropdownButton').click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
await page.getByRole('button', { name: 'Delete Workspace' }).click();
// check workspace is deleted remotely
await expect.soft(insomnia.navigationSidebar.unsyncedWorkspaceRow('My Collection R1')).toBeHidden();
await expect.soft(insomnia.navigationSidebar.workspaceRow('My Collection R1')).toBeHidden();
});
test('Delete an unsynced remote file from the project dashboard', async ({ page, insomnia }) => {
// "Design Project" is only ever remote in this suite, so it is never pulled locally first.
const unsyncedCard = page.getByTestId('workspace-grid').getByLabel('Design Project');
await expect.soft(unsyncedCard).toBeVisible();
await expect.soft(insomnia.navigationSidebar.unsyncedWorkspaceRow('Design Project')).toBeVisible();
await unsyncedCard.hover();
await unsyncedCard.getByLabel('Delete unsynced file').click();
await expect.soft(page.getByRole('heading', { name: 'Delete file' })).toBeVisible();
await page.getByRole('button', { name: 'Delete unsynced file permanently' }).click();
await expect.soft(unsyncedCard).toBeHidden();
await expect.soft(insomnia.navigationSidebar.unsyncedWorkspaceRow('Design Project')).toBeHidden();
await expect.soft(insomnia.navigationSidebar.workspaceRow('Design Project')).toBeHidden();
// The other remote files must be untouched.
await expect.soft(insomnia.navigationSidebar.unsyncedWorkspaceRow('My Collection R1')).toBeVisible();
});
// Regression test for the main-process VCS singleton mutable-state bug: a single `_backendProject`
// field shared across every workspace meant that activating one workspace's backend project while
// another workspace's activation was still in flight could make the second call "win" for both,
// so the loser ended up reading/writing the wrong workspace's local sync data. Each workspace now
// gets its own VCS instance, so concurrently activating two different workspaces must never let
// one clobber the other's active backend project. Uses synthetic workspace ids (not part of the
// mock server's fixtures) since this only exercises local VCS state, no network calls involved.
test('keeps concurrently-activated workspaces on their own backend project', async ({ page }) => {
const workspaceA = { id: 'wrk_concurrency_test_a', name: 'Concurrency Test A' };
const workspaceB = { id: 'wrk_concurrency_test_b', name: 'Concurrency Test B' };
const result = await page.evaluate(
async ({ a, b }) => {
const sync = (window as any).main.sync;
// Fire both workspaces' activation concurrently, the way two open workspaces being
// synced around the same time would on the main process side.
await Promise.all([
sync.switchAndCreateBackendProjectIfNotExist(a.id, a.id, a.name),
sync.switchAndCreateBackendProjectIfNotExist(b.id, b.id, b.name),
]);
const [activeA, activeB] = await Promise.all([
sync.getActiveBackendProject(a.id),
sync.getActiveBackendProject(b.id),
]);
return { activeA, activeB };
},
{ a: workspaceA, b: workspaceB },
);
expect.soft(result.activeA?.rootDocumentId).toBe(workspaceA.id);
expect.soft(result.activeB?.rootDocumentId).toBe(workspaceB.id);
expect.soft(result.activeA?.id).not.toBe(result.activeB?.id);
// Clean up the local backend projects created for this test's synthetic workspace ids.
await page.evaluate(
async ({ a, b }) => {
const sync = (window as any).main.sync;
await sync.removeBackendProjectsForRoot(a.id);
await sync.removeBackendProjectsForRoot(b.id);
},
{ a: workspaceA, b: workspaceB },
);
});
});
@@ -115,7 +115,7 @@ async function addAccessTokenGitCredential(insomnia: InsomniaApp) {
async function createGitDesignDocument(insomnia: InsomniaApp, page: Page, projectName: string) {
await insomnia.navigationSidebar.selectProjectDropdownOption({
actionName: 'Document',
actionName: 'API Collection',
projectName,
});
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Lint Test Spec');
@@ -215,8 +215,8 @@ test.describe('Custom Spectral Lint Rules', () => {
await insomnia.relaunch();
// Re-navigate to the same design document after relaunch. The workspace is
// created with the default name 'My Design Document'.
await insomnia.page.getByLabel('My Design Document').first().click();
// created with the default name 'My API Collection'.
await insomnia.page.getByLabel('My API Collection').first().click();
await expect
.soft(insomnia.page.getByRole('button', { name: 'View selected ruleset content' }))
@@ -243,7 +243,7 @@ test.describe('Custom Spectral Lint Rules', () => {
await expect.soft(insomnia.page.getByText('No lint problems')).toBeVisible({ timeout: 15_000 });
await insomnia.relaunch();
await insomnia.page.getByLabel('My Design Document').first().click();
await insomnia.page.getByLabel('My API Collection').first().click();
await expect.soft(insomnia.page.getByText('Default OAS Ruleset')).toBeVisible({ timeout: 15_000 });
await expect.soft(insomnia.page.getByText(new RegExp(RULESET_RULE_NAME))).toBeHidden();
@@ -264,7 +264,7 @@ test.describe('Custom Spectral Lint Rules', () => {
await expect.soft(insomnia.page.getByText('Invalid Spectral Ruleset', { exact: true })).toBeVisible({
timeout: 10_000,
});
await insomnia.page.getByRole('button', { name: 'Ok' }).click();
await insomnia.page.getByRole('dialog', { name: 'Modal' }).getByRole('button', { name: 'Ok' }).click();
// Default ruleset should still be active; no custom upload button state change.
await expect.soft(insomnia.page.getByText('Default OAS Ruleset')).toBeVisible();
@@ -298,7 +298,7 @@ test.describe('Custom Spectral Lint Rules', () => {
test('upload custom ruleset in a synced design project', async ({ page, insomnia }) => {
await page.getByTestId('workspace-grid').getByLabel('Design Project').click();
await page.waitForURL(/\/workspace\/.+\/spec/, { timeout: 30_000 });
await page.waitForURL(/\/workspace\/.+\/debug/, { timeout: 30_000 });
await uploadRuleset(insomnia, page);
@@ -330,14 +330,14 @@ test.describe('Custom Spectral Lint Rules', () => {
// Machine A: upload and push.
await page.getByTestId('workspace-grid').getByLabel('Design Project').click();
await page.waitForURL(/\/workspace\/.+\/spec/, { timeout: 30_000 });
await page.waitForURL(/\/workspace\/.+\/debug/, { timeout: 30_000 });
await uploadRuleset(insomnia, page);
await commitAndPush(page, 'Add custom lint ruleset');
// Machine B: fresh data path (simulates a different machine), pull, and verify.
const machineB = await insomnia.launchClone(randomDataPath());
await machineB.page.getByTestId('workspace-grid').getByLabel('Design Project').click();
await machineB.page.waitForURL(/\/workspace\/.+\/spec/, { timeout: 30_000 });
await machineB.page.waitForURL(/\/workspace\/.+\/debug/, { timeout: 30_000 });
await expect
.soft(machineB.page.getByRole('button', { name: 'View selected ruleset content' }))
.toBeVisible({ timeout: 15_000 });
@@ -366,7 +366,7 @@ test.describe('Custom Spectral Lint Rules', () => {
// User A: upload and push.
await page.getByTestId('workspace-grid').getByLabel('Design Project').click();
await page.waitForURL(/\/workspace\/.+\/spec/, { timeout: 30_000 });
await page.waitForURL(/\/workspace\/.+\/debug/, { timeout: 30_000 });
await uploadRuleset(insomnia, page);
await commitAndPush(page, 'User A: add custom lint ruleset');
@@ -376,7 +376,7 @@ test.describe('Custom Spectral Lint Rules', () => {
});
await userB.page.getByTestId('workspace-grid').getByLabel('Design Project').click();
await userB.page.waitForURL(/\/workspace\/.+\/spec/, { timeout: 30_000 });
await userB.page.waitForURL(/\/workspace\/.+\/debug/, { timeout: 30_000 });
await expect
.soft(userB.page.getByRole('button', { name: 'View selected ruleset content' }))
@@ -42,7 +42,7 @@ test.describe('Dashboard', () => {
// After deleting project, return to default Insomnia Dashboard
await expect.soft(page.locator('.app')).toContainText('Personal Workspace');
await expect.soft(page.locator('.app')).not.toContainText('My Project123');
await expect.soft(page.locator('.app')).toContainText('Create document');
await expect.soft(page.locator('.app')).toContainText('Enter API Spec');
await expect.soft(page.locator('.app')).not.toContainText('Setup Git Sync');
// Documents
@@ -56,9 +56,9 @@ test.describe('Dashboard', () => {
await page.getByTestId('workspace-breadcrumb-level-0').click();
// Rename document
await page.getByLabel('Files').getByLabel('My Design Document').getByRole('button').click();
await page.getByLabel('Files').getByLabel('My API Collection').getByRole('button').click();
await page.getByRole('menuitem', { name: 'Rename' }).click();
await page.locator('text=Rename DocumentName Rename >> input[type="text"]').fill('test123');
await page.locator('text=Rename API CollectionName Rename >> input[type="text"]').fill('test123');
await page.getByRole('button', { name: 'Rename' }).click();
await expect.soft(page.locator('.app')).toContainText('test123');
@@ -74,14 +74,14 @@ test.describe('Dashboard', () => {
// Create new collection
await page.getByLabel('Create in project').click();
await page.getByRole('menuitemradio', { name: 'Collection' }).click();
await page.getByRole('menuitemradio', { name: 'API Collection' }).click();
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByTestId('workspace-breadcrumb-level-0').click();
// Rename collection
await page.click('text=CollectionMy Collectionjust now >> button');
await page.click('text=API CollectionMy API Collectionjust now >> button');
await page.getByRole('menuitem', { name: 'Rename' }).click();
await page.locator('text=Rename CollectionName Rename >> input[type="text"]').fill('collection123');
await page.locator('text=Rename API CollectionName Rename >> input[type="text"]').fill('collection123');
await page.getByRole('button', { name: 'Rename' }).click();
await expect.soft(page.locator('.app')).toContainText('collection123');
@@ -4,17 +4,21 @@ test.describe('design document operations', () => {
test('can name design documents', async ({ page }) => {
await page.getByRole('button', { name: 'Create document' }).click();
await page.locator('body').click();
await page.getByPlaceholder('Enter a name for your Design Document').fill('jurassic park');
await page.getByPlaceholder('Enter a name for your Design Document').press('Enter');
await page.getByPlaceholder('Enter a name for your API Collection').fill('jurassic park');
await page.getByPlaceholder('Enter a name for your API Collection').press('Enter');
await page.getByTestId('workspace-breadcrumb-level-0').click();
await page.getByLabel('Files').getByLabel('jurassic park').click();
});
test('can delete a test suite with confirmation modal', async ({ page }) => {
test('can delete a test suite with confirmation modal', async ({ page, insomnia }) => {
await page.getByRole('button', { name: 'Create document' }).click();
await page.getByPlaceholder('Enter a name for your Design Document').fill('jurassic park');
await page.getByPlaceholder('Enter a name for your Design Document').press('Enter');
await page.getByTestId('workspace-test').click();
await page.getByPlaceholder('Enter a name for your API Collection').fill('jurassic park');
await page.getByPlaceholder('Enter a name for your API Collection').press('Enter');
// Show test suite in settings
await insomnia.statusbar.openPreferences();
await page.locator('input[name="enableLegacyUnitTests"]').click();
await insomnia.preferencesPage.closePreferences();
await page.getByTestId('api-collection-tab-tests').click();
await page.getByText('New test suite').click();
await page.getByLabel('Test Suites').getByLabel('Unit Test Actions').click();
await page.getByRole('menuitemradio', { name: 'Delete suite' }).click();
@@ -6,7 +6,7 @@ import { test } from '../../playwright/test';
test.describe('Design interactions', () => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
test('Unit Test interactions', async ({ app, page }) => {
test('Unit Test interactions', async ({ app, page, insomnia }) => {
// Setup
const text = await loadFixture('unit-test.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
@@ -14,8 +14,12 @@ test.describe('Design interactions', () => {
await page.locator('[data-test-id="import-from-clipboard"]').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
// Show test suite in settings
await insomnia.statusbar.openPreferences();
await page.locator('input[name="enableLegacyUnitTests"]').click();
await insomnia.preferencesPage.closePreferences();
// Switch to Test tab
await page.click('a:has-text("Test")');
await page.getByTestId('api-collection-tab-tests').click();
// Run tests and check results
await page.getByLabel('Run all tests').click();
@@ -19,9 +19,6 @@ test.describe('Git Sync', () => {
gitSync: {
enabled: false,
},
konnectSync: {
enabled: true,
},
},
},
});
@@ -35,9 +32,6 @@ test.describe('Git Sync', () => {
gitSync: {
enabled: true,
},
konnectSync: {
enabled: true,
},
},
},
});
@@ -41,7 +41,7 @@ test.describe('environment cascade / override', () => {
};
const selectEnvironment = async (page: Page, insomnia: any, environmentName: string, requestName: string) => {
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
// wait for the Manage Environments dialog to close before interacting with the picker
await page.getByRole('heading', { name: 'Manage Environments' }).waitFor({ state: 'hidden' });
@@ -16,16 +16,15 @@ test.describe('Environment Editor', () => {
await page.getByRole('dialog').waitFor({ state: 'hidden' });
// create a new shared environment (becomes active on creation)
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByRole('button', { name: 'Manage collection environments' }).click();
await page.getByTestId('CreateEnvironmentDropdown').click();
await page.getByRole('menuitemradio', { name: 'Shared Environment' }).press('Enter');
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
await page.getByTestId('AddSubEnvironment').click();
// wait for the new row to appear before clicking it
await page.getByRole('row', { name: 'New Environment' }).waitFor({ state: 'visible' });
await page.getByRole('row', { name: 'New Environment' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click();
// wait for the Manage Environments dialog to close before interacting with the picker
await page.getByRole('heading', { name: 'Manage Environments' }).waitFor({ state: 'hidden' });
@@ -36,7 +35,7 @@ test.describe('Environment Editor', () => {
// send request: verify variables fall back to base env (new env is empty)
await insomnia.navigationSidebar.clickRequestOrFolder('New Request');
await page.getByRole('button', { name: 'Send' }).click();
// wait for a response before switching to console
await page.locator('[data-testid="response-status-tag"]:visible').waitFor({ state: 'visible', timeout: 25_000 });
await page.getByRole('tab', { name: 'Console' }).click();
@@ -44,8 +43,8 @@ test.describe('Environment Editor', () => {
await expect.soft(page.getByText('baseenv1')).toBeVisible();
// duplicate ExampleA and rename the copy to Gandalf
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByRole('button', { name: 'Manage collection environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
await page.getByRole('row', { name: 'ExampleA' }).getByLabel('Environment Actions').click();
await page.getByText('Duplicate').click();
@@ -81,8 +80,8 @@ test.describe('Environment Editor', () => {
await expect.soft(page.getByText('subenvB1')).toBeVisible();
// add new variables to Gandalf via JSON editor
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByRole('button', { name: 'Manage collection environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
await page.locator('pre').filter({ hasText: '"exampleNumber": 2222,' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByTestId('CodeEditor').getByRole('textbox').press('Enter');
@@ -92,16 +91,16 @@ test.describe('Environment Editor', () => {
// blur the editor before closing so the debounce flush is triggered by the button's mousedown
await dialog.getByRole('button', { name: 'Close' }).click();
// wait for the Manage Environments dialog to fully close before navigating
await page.getByRole('heading', { name: 'Manage Environments' }).waitFor({ state: 'hidden' });
await page.getByLabel('Manage collection environments').press('Escape');
await page.getByLabel('Manage API collection environments').press('Escape');
await insomnia.navigationSidebar.clickRequestOrFolder('New Request');
// switch to table view and edit Gandalf environment
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByRole('button', { name: 'Manage collection environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
// explicitly select Gandalf so table edits target the correct sub-environment
await page.getByLabel('Environments', { exact: true }).getByText('Gandalf').click();
@@ -167,14 +166,14 @@ test.describe('Environment Editor', () => {
await page.getByRole('menuitemradio', { name: 'JSON' }).click();
await waitForSync();
await secondRow.getByRole('button', { name: 'Edit JSON' }).click();
// wait for the JSON modal before typing
await page.getByRole('dialog').getByTestId('CodeEditor').waitFor({ state: 'visible' });
const bodyEditor = page.getByRole('dialog').getByTestId('CodeEditor').getByRole('textbox');
await bodyEditor.focus();
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.type('{"anotherString":"kvAnotherStr","anotherNumber": 12345}');
// submit and wait for the JSON modal to fully close before proceeding
await page.getByRole('button', { name: 'Modal Submit' }).click();
await page.getByRole('dialog', { name: 'Modal' }).waitFor({ state: 'hidden' });
@@ -187,7 +186,9 @@ test.describe('Environment Editor', () => {
// dismiss the environment picker dropdown if it appeared
await page.locator('body').click();
try {
await page.getByRole('listbox', { name: 'Select a Collection Environment' }).waitFor({ state: 'hidden', timeout: 3000 });
await page
.getByRole('listbox', { name: 'Select an API Collection Environment' })
.waitFor({ state: 'hidden', timeout: 3000 });
} catch {
await page.keyboard.press('Escape');
}
@@ -217,7 +218,7 @@ test.describe('Environment Editor', () => {
await page.getByRole('dialog').waitFor({ state: 'hidden' });
// activate ExampleA environment
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('option', { name: 'ExampleA' }).press('Enter');
await page.getByRole('option', { name: 'ExampleA' }).press('Escape');
@@ -230,8 +231,8 @@ test.describe('Environment Editor', () => {
await expect.soft(page.getByText('subenvA0')).toBeVisible();
// open env editor, select ExampleA, switch to table view, disable exampleString
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByRole('button', { name: 'Manage collection environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
await page.getByLabel('Environments', { exact: true }).getByText('ExampleA').click();
await page.getByRole('button', { name: 'Table Edit' }).click();
const kvTable = page.getByRole('listbox', { name: 'Environment Key Value Pair' });
@@ -246,7 +247,9 @@ test.describe('Environment Editor', () => {
// dismiss the environment picker dropdown if it appeared
await page.locator('body').click();
try {
await page.getByRole('listbox', { name: 'Select a Collection Environment' }).waitFor({ state: 'hidden', timeout: 3000 });
await page
.getByRole('listbox', { name: 'Select an API Collection Environment' })
.waitFor({ state: 'hidden', timeout: 3000 });
} catch {
await page.keyboard.press('Escape');
}
@@ -261,4 +264,124 @@ test.describe('Environment Editor', () => {
await expect.soft(page.getByText('baseenv0')).toBeVisible();
await expect.soft(page.getByText('subenvA0')).toBeHidden();
});
test('project and collection environment dropdowns open and close independently', async ({ page }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
const projectListbox = page.getByRole('listbox', { name: 'Select a Project Environment' });
const collectionListbox = page.getByRole('listbox', { name: 'Select an API Collection Environment' });
// opening the collection dropdown does not open the project dropdown
await page.getByLabel('Select an API Collection Environment').click();
await expect.soft(collectionListbox).toBeVisible();
await expect.soft(projectListbox).toBeHidden();
await page.keyboard.press('Escape');
await expect.soft(collectionListbox).toBeHidden();
// opening the project dropdown does not open the collection dropdown
await page.getByLabel('Select a Project Environment').click();
await expect.soft(projectListbox).toBeVisible();
await expect.soft(collectionListbox).toBeHidden();
await page.keyboard.press('Escape');
await expect.soft(projectListbox).toBeHidden();
});
test('Add Project Environment creates a project environment without activating it', async ({ page, insomnia }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
// the "+" button in the project dropdown header opens the create-workspace modal
await page.getByLabel('Select a Project Environment').click();
await page.getByLabel('Add Project Environment').click();
await page.getByPlaceholder('Enter a name for your Environment').fill('My New Project Env');
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByRole('dialog', { name: 'Create or update dialog' }).waitFor({ state: 'hidden' });
await insomnia.pressEscape();
// creating navigates into the new environment's own page; go back to the collection
await insomnia.navigationSidebar.selectWorkspace('My first collection');
// the new environment shows up in the project dropdown's list, but is not auto-selected
await expect.soft(page.getByLabel('Select a Project Environment')).toContainText('No Project Environment');
await page.getByLabel('Select a Project Environment').click();
await expect.soft(page.getByRole('option', { name: 'My New Project Env' })).toBeVisible();
});
test('Add Sub Environment and Add Private Sub Environment create environments with the correct privacy', async ({
page,
}) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
// create the shared sub-environment and rename it so it can be told apart from the private one
await page.getByTestId('AddSubEnvironment').click();
const sharedRow = page.getByRole('row', { name: 'New Environment' });
await sharedRow.waitFor({ state: 'visible' });
await sharedRow.locator('[data-editable=true]').dblclick();
await sharedRow.locator('input').fill('Shared Sub Env');
await sharedRow.locator('input').press('Enter');
await page.getByRole('row', { name: 'Shared Sub Env' }).waitFor({ state: 'visible' });
await expect.soft(page.getByRole('row', { name: 'Shared Sub Env' }).locator('.fa-lock')).toHaveCount(0);
// create the private sub-environment
await page.getByTestId('AddPrivateSubEnvironment').click();
const privateRow = page.getByRole('row', { name: 'New Environment' });
await privateRow.waitFor({ state: 'visible' });
await expect.soft(privateRow.locator('.fa-lock')).toBeVisible();
});
test('clearing the JSON editor reports a JSON error instead of keeping stale table values', async ({ page, app }) => {
const text = await loadFixture('environments.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByLabel('Import').click();
await page.locator('[data-test-id="import-from-clipboard"]').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
// wait for import dialog to close before proceeding
await page.getByRole('dialog').waitFor({ state: 'hidden' });
// open the JSON environment in the environment editor
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
const environmentDialog = page.getByRole('dialog', { name: 'Manage Environments' });
await environmentDialog.getByLabel('Environments', { exact: true }).getByText('ExampleA').click();
// delete all JSON content
const jsonEditor = environmentDialog.getByTestId('CodeEditor').getByRole('textbox');
await jsonEditor.focus();
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.press('Delete');
/* Empty content must be reported like any other parse error, not silently dropped. */
await expect.soft(environmentDialog.getByText('Unexpected end of JSON input')).toBeVisible();
/* The existing JSON error dialog blocks the switch, so the table can never show values that no
longer match the editor content. */
await page.getByRole('button', { name: 'Table Edit' }).click();
await expect
.soft(page.getByText('Please modify and fix the JSON string error before switch to Table view'))
.toBeVisible();
await expect.soft(page.getByRole('listbox', { name: 'Environment Key Value Pair' })).toBeHidden();
await page.getByRole('button', { name: 'Ok', exact: true }).click();
/* Reopening remounts the editor and restores the unsaved empty content from its cache: the error
must survive, otherwise the document is empty but still switchable. */
await page.getByRole('button', { name: 'Close', exact: true }).click();
await page.getByRole('heading', { name: 'Manage Environments' }).waitFor({ state: 'hidden' });
// the picker popover can still be open; this click dismisses it, then reopen the editor
await page.locator('body').click();
await page.getByRole('button', { name: 'Select an API Collection Environment' }).click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
await page.getByRole('dialog', { name: 'Manage Environments' }).waitFor({ state: 'visible' });
await environmentDialog.getByLabel('Environments', { exact: true }).getByText('ExampleA').click();
await expect.soft(environmentDialog.getByText('Unexpected end of JSON input')).toBeVisible();
await page.getByRole('button', { name: 'Table Edit' }).click();
await expect
.soft(page.getByText('Please modify and fix the JSON string error before switch to Table view'))
.toBeVisible();
await expect.soft(page.getByRole('listbox', { name: 'Environment Key Value Pair' })).toBeHidden();
});
});
@@ -0,0 +1,74 @@
import { expect } from '@playwright/test';
import { test } from '../../playwright/test';
// An Event Stream (SSE) response stores its NDJSON event log in `bodyPath`, exactly where a plain
// HTTP response keeps its raw body, so what counts as an event has to come from the content. Flipping
// `Accept` to text/event-stream after sending used to feed that HTTP body to the events table, which
// threw `Could not determine key for item` and blanked the whole response pane.
test('Event Stream: an HTTP response is not re-read as an event log after flipping the Accept header', async ({
page,
insomnia,
}) => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
const responsePane = page.getByTestId('response-pane').first();
const responseBody = responsePane.locator('[data-testid="CodeEditor"]:visible');
const requestPane = page.getByTestId('request-pane');
// Compact single-line JSON: a pretty-printed body would fail to parse line by line, hiding the bug.
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('My first collection');
await page.getByRole('menuitemradio', { name: 'From Curl' }).click();
await page.getByRole('dialog').locator('.CodeMirror textarea').fill('curl --url http://127.0.0.1:4010/pets/1');
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await expect.soft(requestPane.getByTestId('OneLineEditor').getByText('http://127.0.0.1:4010/pets/1')).toBeVisible();
await requestPane.getByRole('button', { name: 'Send' }).click();
await expect.soft(responsePane.getByTestId('response-status-tag')).toContainText('200', { timeout: 10_000 });
await expect.soft(responseBody).toContainText('"id"');
// Flip the request to an Event Stream by adding its Accept header.
await requestPane.getByRole('tab', { name: 'Headers' }).click();
const listbox = requestPane.getByRole('listbox', { name: 'Key-value pairs', exact: true });
await listbox.getByRole('option').last().getByTestId('OneLineEditor').first().locator('.CodeMirror').click();
await page.keyboard.type('Accept');
// The tab badge counts persisted headers plus the read-only rows (3), so 4 means the commit landed.
await expect.soft(requestPane.getByRole('tab', { name: 'Headers 4' })).toBeVisible();
const acceptRow = listbox.getByRole('option').filter({ hasText: 'Accept' }).first();
await acceptRow.getByTestId('OneLineEditor').nth(1).locator('.CodeMirror').click();
await page.keyboard.type('text/event-stream');
await expect.soft(requestPane.getByRole('button', { name: 'Connect' })).toBeVisible({ timeout: 5000 });
// The request is an Event Stream now: its response is rendered by the stream pane, and the stored
// HTTP body is not turned into events (only event shaped lines are). This used to throw in the
// events table and blank the whole pane.
await expect.soft(responsePane.getByRole('tab', { name: 'Events' })).toBeVisible();
await expect.soft(responsePane.getByTestId('response-status-tag')).toContainText('200');
});
test('Event Stream: connecting shows the received events in the events view', async ({ page, insomnia }) => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
const requestPane = page.getByTestId('request-pane');
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('My first collection');
await page.getByRole('menuitemradio', { name: 'From Curl' }).click();
await page
.getByRole('dialog')
.locator('.CodeMirror textarea')
.fill(`curl --url http://127.0.0.1:4010/sse -H 'Accept: text/event-stream'`);
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await expect.soft(requestPane.getByTestId('OneLineEditor').getByText('http://127.0.0.1:4010/sse')).toBeVisible();
await requestPane.getByRole('button', { name: 'Connect' }).click();
const responsePane = page.getByTestId('response-pane').first();
await expect.soft(responsePane.getByTestId('response-status-tag')).toContainText('200', { timeout: 10_000 });
// The events view is what renders a stream response - the raw body would only show the NDJSON log.
await expect.soft(responsePane.getByRole('tab', { name: 'Events' })).toBeVisible();
await expect.soft(responsePane.getByRole('columnheader', { name: 'Data' })).toBeVisible();
await expect.soft(responsePane.getByText('hello-from-sse-1')).toBeVisible();
await expect.soft(responsePane.getByText('hello-from-sse-2')).toBeVisible();
});
@@ -0,0 +1,120 @@
import path from 'node:path';
import { expect, type Page } from '@playwright/test';
import { test } from '../../playwright/test';
import {
cleanupExportDir,
createTempExportDir,
mockSaveDialogForFile,
readExportedFile,
waitForExportFiles,
} from '../../playwright/utils';
test.describe('Export OpenAPI Spec', () => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
// Creates an API collection and fills its spec with the Pet Store example
const createApiCollectionWithPetStoreSpec = async (page: Page) => {
await page.getByRole('button', { name: 'Create document' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
await page.click('text=Use example');
await page.click('text=Pet Store');
await expect.soft(page.locator('.pane-one').getByTestId('CodeEditor')).toContainText('openapi: 3.0.4');
};
test('exports the spec of an API collection as YAML from the sidebar dropdown', async ({ page, app, insomnia }) => {
await createApiCollectionWithPetStoreSpec(page);
const tempDir = createTempExportDir();
try {
const exportPath = path.join(tempDir, 'spec-export.yaml');
await mockSaveDialogForFile(app, exportPath);
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Export OpenAPI Spec',
workspaceName: 'My API Collection',
});
// The format modal defaults to YAML
await insomnia.exportModal.selectExportFormat('yaml');
await waitForExportFiles(tempDir, 1);
const contents = readExportedFile(exportPath);
expect.soft(contents).toContain('openapi: 3.0.4');
expect.soft(contents).toContain('Pet Store');
} finally {
cleanupExportDir(tempDir);
}
});
test('exports the spec of an API collection as JSON from the sidebar dropdown', async ({ page, app, insomnia }) => {
await createApiCollectionWithPetStoreSpec(page);
const tempDir = createTempExportDir();
try {
const exportPath = path.join(tempDir, 'spec-export.json');
await mockSaveDialogForFile(app, exportPath);
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Export OpenAPI Spec',
workspaceName: 'My API Collection',
});
await insomnia.exportModal.selectExportFormat('json');
await waitForExportFiles(tempDir, 1);
const contents = JSON.parse(readExportedFile(exportPath));
expect.soft(contents).toMatchObject({ openapi: '3.0.4' });
expect.soft(contents.info.title).toContain('Pet');
} finally {
cleanupExportDir(tempDir);
}
});
test('offers Export OpenAPI Spec when the API collection is opened via right-click', async ({ page, insomnia }) => {
await createApiCollectionWithPetStoreSpec(page);
// Right-click opens the dropdown via the controlled isOpen prop, not the trigger
await insomnia.navigationSidebar.workspaceRow('My API Collection').click({ button: 'right' });
await expect.soft(page.getByRole('menuitemradio', { name: 'Export OpenAPI Spec' })).toBeVisible();
await page.keyboard.press('Escape');
});
test('prompts an error while the spec is empty, then exports once it is filled in', async ({
page,
app,
insomnia,
}) => {
await page.getByRole('button', { name: 'Create document' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
// Empty spec: the item is offered, but clicking it explains there is nothing to export
// instead of writing an empty file.
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('My API Collection');
await page.getByRole('menuitemradio', { name: 'Export OpenAPI Spec' }).click();
await expect.soft(page.getByText('does not contain an OpenAPI specification to export')).toBeVisible();
await page.getByRole('button', { name: 'Modal Close Button' }).click();
// Fill the spec in: the same entry point now exports the file
const tempDir = createTempExportDir();
try {
const exportPath = path.join(tempDir, 'spec-export.yaml');
await mockSaveDialogForFile(app, exportPath);
await page.click('text=Use example');
await page.click('text=Pet Store');
await expect.soft(page.locator('.pane-one').getByTestId('CodeEditor')).toContainText('openapi: 3.0.4');
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Export OpenAPI Spec',
workspaceName: 'My API Collection',
});
await insomnia.exportModal.selectExportFormat('yaml');
await waitForExportFiles(tempDir, 1);
expect.soft(readExportedFile(exportPath)).toContain('openapi: 3.0.4');
} finally {
cleanupExportDir(tempDir);
}
});
});
@@ -52,10 +52,9 @@ test.describe('Focus and keyboard navigation', () => {
test('the KV environment editor focuses the blank row Name', async ({ page }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByRole('button', { name: 'Manage collection environments' }).click();
await page.getByTestId('CreateEnvironmentDropdown').click();
await page.getByRole('menuitemradio', { name: 'Shared Environment' }).press('Enter');
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
await page.getByTestId('AddSubEnvironment').click();
// New environments default to KV mode, so selecting the new (empty) environment renders the KV
// editor whose trailing blank row's Name should be focused.
@@ -83,7 +83,7 @@ test.describe('Git Sync', () => {
await page.getByRole('textbox', { name: 'File name' }).press('ControlOrMeta+a');
await page.getByRole('textbox', { name: 'File name' }).fill('collection_1');
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByText('Create a new Request Collection').waitFor({ state: 'hidden' });
await page.getByText('Create a new API Collection').waitFor({ state: 'hidden' });
// Creating the collection focused the sidebar on it; back out before selecting the project.
await insomnia.navigationSidebar.backToAllProjects();
await insomnia.navigationSidebar.selectProject(GIT_PROJECT_NAME);
@@ -170,7 +170,7 @@ test.describe('Git Sync', () => {
await page.getByRole('textbox', { name: 'File name' }).press('ControlOrMeta+a');
await page.getByRole('textbox', { name: 'File name' }).fill('discard_test_collection');
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByText('Create a new Request Collection').waitFor({ state: 'hidden' });
await page.getByText('Create a new API Collection').waitFor({ state: 'hidden' });
await page.getByTestId('git-dropdown').click();
await page.getByRole('menuitemradio', { name: 'Commit' }).click();
@@ -201,7 +201,7 @@ test.describe('Git Sync', () => {
// Only Name was touched. Pre-fix, the auto-mirrored File name field also counted
// as changed, pushing changedFieldCount to 2 and wrongly triggering this prompt.
await page.keyboard.press('Escape');
await page.getByText('Create a new Request Collection').waitFor({ state: 'hidden' });
await page.getByText('Create a new API Collection').waitFor({ state: 'hidden' });
await expect.soft(page.getByRole('heading', { name: 'Unsaved changes' })).toBeHidden();
await insomnia.navigationSidebar.selectProjectDropdownOption({
@@ -222,7 +222,7 @@ test.describe('Git Sync', () => {
await page.getByRole('button', { name: 'No' }).click();
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Yes' }).click();
await page.getByText('Create a new Request Collection').waitFor({ state: 'hidden' });
await page.getByText('Create a new API Collection').waitFor({ state: 'hidden' });
});
});
@@ -29,8 +29,7 @@ test.describe('Global Environments', () => {
await page.getByRole('button', { name: 'Send' }).click();
await page.getByRole('heading', { name: '2 environment variables are' }).click();
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByLabel('Manage Environments').click();
await page.getByPlaceholder('Choose a project environment').click();
await page.getByLabel('Select a Project Environment').click();
await page.getByRole('option', { name: 'global-environment' }).click();
await page.getByText('New Environment').click();
await page.locator('body').click();
@@ -49,8 +48,7 @@ test.describe('Global Environments', () => {
await page.getByLabel('Create in project').click();
await page.getByLabel('Create', { exact: true }).getByText('Environment').click();
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByTestId('CreateEnvironmentDropdown').click();
await page.getByText('Private environment').click();
await page.getByTestId('AddPrivateSubEnvironment').click();
await page.getByLabel('New Environment').click();
await page.getByLabel('New Environment').getByLabel('Project Actions').click();
await page.getByText('Duplicate').click();
@@ -0,0 +1,65 @@
import type { ElectronApplication } from '@playwright/test';
import { expect } from '@playwright/test';
import { test } from '../../playwright/test';
// OS-level deep links can't be tested in CI: protocol registration is
// machine-global and macOS delivery needs a packaged, protocol-registered
// build. Instead, tests send the `shell:open` IPC directly to the app's
// windows — the same channel the main process uses for real deep links —
// exercising the renderer deep-link handler in root.tsx → ImportModal with
// zero production-code changes. The main-process protocol hop is covered by
// manual testing.
const sendDeepLink = (app: ElectronApplication, url: string) =>
app.evaluate(({ BrowserWindow }, deepLinkUrl) => {
// Send outside evaluate()'s synchronous context. Only the main window has
// a `shell:open` listener; sending to the others is a harmless no-op.
setImmediate(() => {
BrowserWindow.getAllWindows().forEach(window => {
window.webContents.send('shell:open', deepLinkUrl);
});
});
}, url);
test.describe('import deep links', () => {
test.beforeEach(async ({ insomnia }) => {
// `webContents.send` drops the event if the renderer's `shell:open`
// listener (registered in Root) isn't up yet, so wait for the app shell.
await insomnia.page.getByRole('banner').waitFor();
});
test('bare clipboard param opens the Import modal on the Clipboard tab', async ({ app, insomnia }) => {
await sendDeepLink(app, 'insomnia://app/import?clipboard');
await expect.soft(insomnia.page.getByLabel('Import from')).toBeVisible();
// The app uses the non-standard `data-test-id` attribute (see playwright/pages).
await expect.soft(insomnia.page.locator('[data-test-id="import-from-clipboard"]')).toBeVisible();
await expect.soft(insomnia.page.locator('input[name="source"][value="clipboard"]')).toBeChecked();
});
test('bare curl param selects the cURL tab with an empty input and no error', async ({ app, insomnia }) => {
// Param names are case-insensitive.
await sendDeepLink(app, 'insomnia://app/import?CuRl');
await expect.soft(insomnia.page.getByLabel('Import from')).toBeVisible();
const curlInput = insomnia.page.locator('textarea[name="curl"]');
await expect.soft(curlInput).toBeVisible();
await expect.soft(curlInput).toHaveValue('');
// Empty pre-populated value must not surface the invalid-curl error.
await expect.soft(insomnia.page.getByText('Invalid cURL request')).toHaveCount(0);
});
test('value-bearing curl param auto-scans and shows the detected request', async ({ app, insomnia }) => {
const curlCommand = 'curl --request GET --url http://insomnia.rest/';
await sendDeepLink(app, `insomnia://app/import?curl=${encodeURIComponent(curlCommand)}`);
await expect.soft(insomnia.page.getByRole('dialog')).toBeVisible();
// A value-bearing param auto-scans: the modal skips the source picker and
// shows the detected resources ready to import. The scan completes within
// milliseconds of the modal opening, so assert on the stable post-scan
// state — the pre-scan form (whose textarea value contains the URL) is
// replaced as soon as the scan result lands.
await expect.soft(insomnia.page.getByText('cURL resources to be imported:')).toBeVisible();
await expect.soft(insomnia.page.getByText('1 Request')).toBeVisible();
});
});
@@ -96,8 +96,7 @@ test.describe('Check vault used in environment', () => {
await page.getByLabel('Create', { exact: true }).getByText('Environment').click();
await page.getByPlaceholder('Enter a name for your Environment').fill('New Global Vault Environment');
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByTestId('CreateEnvironmentDropdown').click();
await page.getByText('Private environment').click();
await page.getByTestId('AddPrivateSubEnvironment').click();
// activate created private environment
await page.getByRole('grid', { name: 'Environments' }).getByText('New Environment').click();
@@ -153,10 +152,10 @@ test.describe('Check vault used in environment', () => {
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
// activate existing global private vault environment from import
await page.getByLabel('Manage Environments').click();
await page.getByPlaceholder('Choose a project environment').click();
await page.getByRole('option', { name: 'New Global Vault Environment' }).click();
await page.getByLabel('Select a Project Environment').click();
await page.getByRole('option', { name: 'New Environment' }).click();
await page.keyboard.press('Escape');
await page.getByLabel('Select an API Collection Environment').click();
await page.getByText('Base Environment1').click();
await page.locator('body').click();
@@ -176,12 +175,12 @@ test.describe('Check vault used in environment', () => {
await page.locator('.app').press('Escape');
// activate global private vault environment from import
await page.getByLabel('Manage Environments').click();
await page.getByPlaceholder('Choose a project environment').click();
await page.getByRole('option', { name: 'Global env workspace with secret vault' }).click();
await page.getByLabel('Select a Project Environment').click();
await page.getByText('global vault env with secret').click();
await page.keyboard.press('Escape');
// activate legacy array vault environment
await page.getByLabel('Select an API Collection Environment').click();
await page.getByText('legacy vault value array').click();
await page.locator('body').click();
// activate request
@@ -201,7 +200,7 @@ test.describe('Check vault used in environment', () => {
await page.getByText('vault_array_b').click();
// activate legacy object vault environment
await page.getByLabel('Manage Environments').click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByText('legacy vault value object').click();
await page.locator('body').click();
// activate request
@@ -219,7 +218,7 @@ test.describe('Check vault used in environment', () => {
await page.getByText('world').click();
// activate invalid vault environment
await page.getByLabel('Manage Environments').click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByText('base with vault').click();
await page.locator('body').click();
// activate request
@@ -9,7 +9,9 @@ import { test } from '../../playwright/test';
// empty). Typing commits it as a real pair and a fresh blank row takes its place.
test.describe('Key-value editor blank row', () => {
test('request headers: blank row is always shown, is not persisted while empty, and commits on typing', async ({ page }) => {
test('request headers: blank row is always shown, is not persisted while empty, and commits on typing', async ({
page,
}) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await page.getByRole('tab', { name: 'Headers' }).click();
@@ -66,8 +68,8 @@ test.describe('Key-value editor blank row', () => {
await page.getByRole('dialog').waitFor({ state: 'hidden' });
// Open the table editor for the ExampleA sub-environment.
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByRole('button', { name: 'Manage collection environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
await page.getByLabel('Environments', { exact: true }).getByText('ExampleA').click();
await page.getByRole('button', { name: 'Table Edit' }).click();
@@ -86,7 +88,10 @@ test.describe('Key-value editor blank row', () => {
await expect.soft(kvTable).toContainText('blankRowKey');
});
test('environment table editor: deleting a committed row does not leave stale text on the blank row', async ({ page, app }) => {
test('environment table editor: deleting a committed row does not leave stale text on the blank row', async ({
page,
app,
}) => {
const text = await loadFixture('environments.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByLabel('Import').click();
@@ -95,8 +100,8 @@ test.describe('Key-value editor blank row', () => {
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await page.getByRole('dialog').waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByRole('button', { name: 'Manage collection environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
await page.getByLabel('Environments', { exact: true }).getByText('ExampleA').click();
await page.getByRole('button', { name: 'Table Edit' }).click();
@@ -0,0 +1,88 @@
import { expect } from '@playwright/test';
import { test } from '../../playwright/test';
// Regression tests for the table views swallowing the space key: the react-aria
// ListBox typeahead intercepts a space keydown (capture phase, preventDefault +
// stopPropagation) whenever its search buffer is non-empty, i.e. within 1s of the
// previous printable key. A name like "My key" could only be typed as "Mykey" plus
// manual arrow-key insertion. `disallowTypeAhead` on the ListBoxes lets every
// keystroke reach the cell's OneLineEditor (CodeMirror).
// Human-like typing delay (~120ms) keeps the typeahead buffer warm between keys, so
// a swallowed space reproduces deterministically; delay:0 bursts do not.
// Exact-text assertions read `.CodeMirror-code`: the textContent of `.CodeMirror`
// includes CodeMirror's internal measure node, which always contains "xxxxxxxxxx".
const humanTypingDelay = 120;
test.describe('Key-value editor space typing', () => {
test('environment table: spaces can be typed directly into names and values', async ({ page, insomnia }) => {
await insomnia.projectPage.importFixture('environments.yaml');
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
await page.getByLabel('Environments', { exact: true }).getByText('ExampleA').click();
await page.getByRole('button', { name: 'Table Edit' }).click();
const kvTable = page.getByRole('listbox', { name: 'Environment Key Value Pair' });
await expect.soft(kvTable).toContainText('exampleString');
const optionsBefore = await kvTable.getByRole('option').count();
// Type a name containing a space without pausing; the space must land in the editor.
// Both call sites share this locator, kept in lockstep on purpose.
const blankRowEditor = () => kvTable.getByRole('option').last().getByTestId('OneLineEditor').first().locator('.CodeMirror');
await blankRowEditor().click();
await page.keyboard.type('My key', { delay: humanTypingDelay });
await expect.soft(kvTable.getByRole('option')).toHaveCount(optionsBefore + 1);
await expect.soft(kvTable).toContainText('My key');
// Multiple consecutive spaces must all be inserted. The exact-text poll bypasses
// toHaveText/toContainText's whitespace normalization, which would hide a
// partially swallowed space. Pin the row by index: after the commit a fresh blank
// row takes over .last().
await blankRowEditor().click();
await page.keyboard.type('val1 val2', { delay: humanTypingDelay });
await expect.soft(kvTable.getByRole('option')).toHaveCount(optionsBefore + 2);
const committedRow = kvTable.getByRole('option').nth(optionsBefore);
await expect.poll(() => committedRow.locator('.CodeMirror-code').first().evaluate(el => el.textContent ?? '')).toBe('val1 val2');
});
test('request headers: spaces can be typed directly into names and values', async ({ page }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await page.getByRole('tab', { name: 'Headers' }).click();
const listbox = page.getByRole('listbox', { name: 'Key-value pairs', exact: true });
// Create a real row via Add instead of typing into the trailing blank row: the
// blank-row commit round-trip drops DOM focus mid-word (separate, pre-existing
// concern). An added row is stable, so slow typing here guards the space fix.
await page.getByRole('button', { name: 'Add', exact: true }).click();
await expect.soft(listbox.getByRole('option')).toHaveCount(2);
await listbox.getByRole('option').first().getByTestId('OneLineEditor').first().locator('.CodeMirror').click();
await page.keyboard.type('My Header', { delay: humanTypingDelay });
await expect.soft(listbox).toContainText('My Header');
// Spaces in the value editor must survive too, including consecutive ones.
const headerValueEditor = listbox.getByRole('option').first().getByTestId('OneLineEditor').nth(1).locator('.CodeMirror-code');
await headerValueEditor.click();
await page.keyboard.type('val1 val2', { delay: humanTypingDelay });
await expect.poll(() => headerValueEditor.evaluate(el => el.textContent ?? '')).toBe('val1 val2');
});
test('query params: spaces can be typed directly into names', async ({ page }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await page.getByRole('tab', { name: 'Params' }).click();
const listbox = page.getByRole('listbox', { name: 'Key-value pairs', exact: true });
// Same bypass as the headers test above: add a stable row instead of typing into
// the trailing blank row, whose commit round-trip drops DOM focus mid-word.
await page.getByRole('button', { name: 'Add', exact: true }).click();
await expect.soft(listbox.getByRole('option')).toHaveCount(2);
// The Add flow autofocuses the new row's name editor (pendingFocusLastRowId),
// so type directly; clicking the editor races the resizable-panel layout here.
await page.keyboard.type('My Param', { delay: humanTypingDelay });
await expect.soft(listbox).toContainText('My Param');
});
});
@@ -2,9 +2,10 @@ import { expect } from '@playwright/test';
import { test } from '../../playwright/test';
test.describe('Konnect sidebar tab', () => {
test('shows intro card without a PAT, configure it, then sync', async ({ page, insomnia }) => {
await page.getByTestId('sidebar-tab-konnect').click();
test.describe('Control Planes organization', () => {
test('shows intro card without a PAT, configure it, then sync', async ({ page }) => {
await page.getByRole('button', { name: 'Organizations' }).click();
await page.getByRole('option', { name: 'Control Planes' }).click();
await expect.soft(page.getByText('Auto-sync your gateway service routes')).toBeVisible();
await page.getByRole('button', { name: 'Configure' }).click();
@@ -12,28 +13,28 @@ test.describe('Konnect sidebar tab', () => {
await page.getByRole('button', { name: 'Connect & Sync' }).click();
await expect.soft(page.getByRole('heading', { name: 'Kong Konnect settings' })).toBeHidden();
// The Sync button and settings gear are both available even with zero projects synced yet.
await expect.soft(page.getByRole('button', { name: 'Sync Konnect' })).toBeVisible();
await page.getByTestId('sidebar-tab-projects').click();
await expect.soft(page.getByRole('button', { name: 'Create new Project' })).toBeVisible();
await expect.soft(page.getByRole('button', { name: 'Konnect settings' })).toBeVisible();
// The Konnect organization never offers manual project creation.
await expect.soft(page.getByRole('button', { name: 'Create new Project' })).toBeHidden();
});
test.describe('with konnectSync feature flag disabled', () => {
test.describe('without the Konnect control planes entitlement', () => {
test.beforeEach(async ({ request }) => {
await request.post('http://127.0.0.1:4010/v1/test-utils/organizations/features', {
data: { features: { gitSync: { enabled: true }, konnectSync: { enabled: false } } },
await request.post('http://127.0.0.1:4010/v1/test-utils/user/entitlements', {
data: { entitlements: [] },
});
});
test.afterEach(async ({ request }) => {
await request.post('http://127.0.0.1:4010/v1/test-utils/organizations/features', {
data: { features: { gitSync: { enabled: true }, konnectSync: { enabled: true } } },
});
await request.post('http://127.0.0.1:4010/v1/test-utils/user/entitlements', { data: {} });
});
test('hides the Konnect tab', async ({ page }) => {
test('hides the Control Planes organization', async ({ page }) => {
await page.reload({ waitUntil: 'networkidle' });
await expect.soft(page.getByTestId('sidebar-tab-konnect')).toBeHidden();
await page.getByRole('button', { name: 'Organizations' }).click();
await expect.soft(page.getByRole('option', { name: 'Control Planes' })).toBeHidden();
});
});
});
@@ -18,12 +18,10 @@ test('can use client certificate for mTLS', async ({ app, page, insomnia }) => {
const certsDialog = page.getByRole('dialog');
await page.getByTestId('settings-button').click();
await page.getByTestId('dataFolders').fill(getFixturePath(path.join('certificates', 'client')));
const certificatesFixturePath = getFixturePath('certificates');
await page.getByTestId('dataFolders').fill(certificatesFixturePath);
await page.getByTestId('dataFolders-btn').click();
await expect.soft(page.getByText('client')).toBeVisible({ timeout: UI_TIMEOUT });
await page.getByTestId('dataFolders').fill(getFixturePath(path.join('certificates', 'rootCA.pem')));
await page.getByTestId('dataFolders-btn').click();
await expect.soft(page.getByText('rootCA.pem')).toBeVisible({ timeout: UI_TIMEOUT });
await expect.soft(page.getByText(certificatesFixturePath, { exact: true })).toBeVisible({ timeout: UI_TIMEOUT });
await page.locator('.app').press('Escape');
// wait for settings dialog to fully close before continuing
await page.getByTestId('settings-button').waitFor({ state: 'visible', timeout: UI_TIMEOUT });
@@ -402,18 +402,21 @@ test.describe('pre-request features tests', () => {
await expect.soft(responsePane).toContainText('Trying 127.0.0.1:8888'); // updated proxy
});
test('update clientCertificate if request url contains tag', async ({ page, insomnia }) => {
test('update clientCertificate if request url contains tag', async ({ app, page, insomnia }) => {
const responsePane = page.getByTestId('response-pane');
const fixturePath = getFixturePath('certificates');
// the pre-request script references its cert/key by a path relative to the app's cwd,
// so allow-list that directory rather than the file's own (non-existent) name
const appCwd = await app.evaluate(() => process.cwd());
await page.getByTestId('settings-button').click();
await page.getByTestId('dataFolders').fill(getFixturePath('fake.pfx'));
await page.getByTestId('dataFolders-btn').click();
await expect.soft(page.getByText('fake.pfx')).toBeVisible();
await page.getByTestId('dataFolders').fill('invalid');
await page.getByTestId('dataFolders').fill(appCwd);
await page.getByTestId('dataFolders-btn').click();
await expect.soft(page.getByText('invalid')).toBeVisible();
await expect.soft(page.getByText(appCwd)).toBeVisible();
await page.locator('.app').press('Escape');
@@ -439,14 +442,17 @@ test.describe('pre-request features tests', () => {
await expect.soft(responsePane).toContainText('Adding SSL KEY certificate');
});
test('insomnia.request / update clientCertificate', async ({ page, insomnia }) => {
test('insomnia.request / update clientCertificate', async ({ app, page, insomnia }) => {
const responsePane = page.getByTestId('response-pane');
await insomnia.navigationSidebar.clickRequestOrFolder('test certificate manipulation');
// the pre-request script references its cert/key by a path relative to the app's cwd,
// so allow-list that directory rather than the file's own (non-existent) name
const appCwd = await app.evaluate(() => process.cwd());
await page.getByTestId('settings-button').click();
await page.getByTestId('dataFolders').fill('invalid');
await page.getByTestId('dataFolders').fill(appCwd);
await page.getByTestId('dataFolders-btn').click();
await expect.soft(page.getByText('invalid')).toBeVisible();
await expect.soft(page.getByText(appCwd)).toBeVisible();
await page.locator('.app').press('Escape');
// send
@@ -486,8 +492,9 @@ test.describe('pre-request features tests', () => {
await expect.soft(statusTag).toContainText('200 OK');
// verify persisted environment
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByRole('button', { name: 'Manage collection environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('button', { name: 'Manage API collection environments' }).click();
const responseBody = page.getByRole('dialog').getByTestId('CodeEditor').locator('.CodeMirror-line');
const rows = await responseBody.allInnerTexts();
const bodyJson = JSON.parse(rows.join(' '));
@@ -528,10 +535,8 @@ test.describe('pre-request features tests', () => {
});
// activate global environment
await page.getByLabel('Manage Environments').click();
await page.getByPlaceholder('Choose a project environment').click();
await page.getByRole('option', { name: 'Script Environment' }).click();
await page.getByRole('option', { name: 'Base Script Env' }).click();
await page.getByLabel('Select a Project Environment').click();
await page.getByRole('option', { name: 'Script Environment', exact: true }).click();
await page.locator('body').click();
// send
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
@@ -540,8 +545,9 @@ test.describe('pre-request features tests', () => {
await page.getByText('log: globals base').click();
await page.getByText('log: baseGlobals base').click();
// view base environment has been updated
await page.getByLabel('Manage Environments').click();
await page.getByLabel('Manage project environment').click();
await page.getByLabel('Select a Project Environment').click();
await page.getByRole('option', { name: 'Script Environment', exact: true }).hover();
await page.getByLabel('Edit Script Environment').click();
await page.getByLabel('Environment name').getByText('Base Script Env').click();
const globalBaseEditor = page.getByTestId('CodeEditor').locator('.CodeMirror-line');
const globalBaseRows = await globalBaseEditor.allInnerTexts();
@@ -556,7 +562,7 @@ test.describe('pre-request features tests', () => {
// switch back to request collection tab
await page.getByLabel('Insomnia Tabs').getByLabel('persist global environment').first().click();
// activate global sub environment
await page.getByLabel('Manage Environments').click();
await page.getByLabel('Select a Project Environment').click();
await page.getByRole('option', { name: 'Sub Script Env' }).click();
await page.locator('body').click();
// send
@@ -566,8 +572,9 @@ test.describe('pre-request features tests', () => {
await page.getByText('log: globals sub').click();
await page.getByText('log: baseGlobals base').click();
// view sub environment has been updated
await page.getByLabel('Manage Environments').click();
await page.getByLabel('Manage project environment').click();
await page.getByLabel('Select a Project Environment').click();
await page.getByRole('option', { name: 'Script Environment', exact: true }).hover();
await page.getByLabel('Edit Script Environment').click();
await page.getByLabel('Environment name').getByText('Sub Script Env').first().click();
const globalSubEditor = page.getByTestId('CodeEditor').locator('.CodeMirror-line');
const globalSubRows = await globalSubEditor.allInnerTexts();
@@ -583,8 +590,8 @@ test.describe('pre-request features tests', () => {
const statusTag = page.locator('[data-testid="response-status-tag"]:visible');
await insomnia.navigationSidebar.clickRequestOrFolder('update kv pair environment');
// switch to table view environment
await page.getByLabel('Manage Environments').click();
const manageBtn = page.getByRole('button', { name: 'Manage collection environments' });
await page.getByLabel('Select an API Collection Environment').click();
const manageBtn = page.getByRole('button', { name: 'Manage API collection environments' });
await expect.soft(manageBtn).toBeEnabled();
await manageBtn.click();
const tableEditBtn = page.getByLabel('Table Edit');
@@ -604,10 +611,10 @@ test.describe('pre-request features tests', () => {
await expect.soft(statusTag).toContainText('200 OK');
// verify table environments have been updated
const verifyManageBtn = page.getByRole('button', { name: 'Manage Environments' });
const verifyManageBtn = page.getByLabel('Select an API Collection Environment');
await expect.soft(verifyManageBtn).toBeEnabled();
await verifyManageBtn.click();
const verifyCollectionBtn = page.getByRole('button', { name: 'Manage collection environments' });
const verifyCollectionBtn = page.getByRole('button', { name: 'Manage API collection environments' });
await expect.soft(verifyCollectionBtn).toBeEnabled();
await verifyCollectionBtn.click();
await page.getByText('__environment_type').click();
@@ -26,7 +26,7 @@ test.describe('test hidden window handling', () => {
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('Pre-request Scripts');
await page.getByRole('menuitemradio', { name: 'Export' }).click();
await page.getByRole('menuitemradio', { name: 'Export', exact: true }).click();
await page.getByRole('button', { name: 'Export' }).click();
await page.getByText('Which format would you like to export as?').click();
await page.locator('.app').press('Escape');
@@ -19,7 +19,8 @@ import { test } from '../../playwright/test';
// script that calls an API this minimal engine doesn't bridge, turning an otherwise-successful send
// into a displayed "Error" status. Asserting via the Console tab (console.log output) instead of an
// echoed response body sidesteps template-rendering entirely.
test.describe('QuickJS script sandbox', () => {
// TODO(INS-3890): re-enable once the QuickJS sandbox setting is un-hidden in scripting-settings.tsx.
test.describe.skip('QuickJS script sandbox', () => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
test.beforeEach(async ({ app, page }) => {
@@ -12,7 +12,8 @@ import { test } from '../../playwright/test';
// `packages/insomnia/src/scripting/quickjs-script-engine.test.ts`'s "sendRequest bridge teardown"
// block, which reproduces the `gc_obj_list` abort in vitest by settling the mocked fetch on a later
// macrotask.
test.describe('QuickJS sendRequest bridge', () => {
// TODO(INS-3890): re-enable once the QuickJS sandbox setting is un-hidden in scripting-settings.tsx.
test.describe.skip('QuickJS sendRequest bridge', () => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
test.beforeEach(async ({ app, page }) => {
@@ -61,7 +61,7 @@ test.describe('runner features tests', () => {
test('run collection runner', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
@@ -104,7 +104,7 @@ test.describe('runner features tests', () => {
test('shows live progress and cancels an in-progress run', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
@@ -126,7 +126,7 @@ test.describe('runner features tests', () => {
test('skips the in-flight request and continues to the next', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
@@ -145,7 +145,7 @@ test.describe('runner features tests', () => {
test('run collection runner with data upload', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
// upload data
@@ -186,7 +186,7 @@ test.describe('runner features tests', () => {
test('run req4 3 times with setNextRequest the pre-request script', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
await selectRunnerRequests(page, 'req4');
@@ -204,7 +204,7 @@ test.describe('runner features tests', () => {
test('await test works', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
await selectRunnerRequests(page, 'await-test');
@@ -222,7 +222,7 @@ test.describe('runner features tests', () => {
test('run req5 3 times with setNextRequest in the after-response script', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
await selectRunnerRequests(page, 'req5');
@@ -240,7 +240,7 @@ test.describe('runner features tests', () => {
test('skip req01 with setNextRequest', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
await selectRunnerRequests(page, 'req0', 'req01', 'req02');
@@ -263,7 +263,7 @@ test.describe('runner features tests', () => {
test('can read variables during whole execution', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
@@ -282,7 +282,7 @@ test.describe('runner features tests', () => {
test('can detect sync and async test failure', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
@@ -301,7 +301,7 @@ test.describe('runner features tests', () => {
test('delay input can be cleared to enter a new value', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
@@ -321,7 +321,7 @@ test.describe('runner features tests', () => {
test('running with cleared delay input uses last valid value', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
@@ -342,7 +342,7 @@ test.describe('runner features tests', () => {
test('iterations input can be cleared to enter a new value', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
@@ -362,7 +362,7 @@ test.describe('runner features tests', () => {
test('running with cleared iterations input uses last valid value', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
@@ -387,7 +387,7 @@ test.describe('runner features tests', () => {
test('can turn off logs via settings', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Run Collection',
actionName: 'Run API Collection',
workspaceName: 'Runner',
});
@@ -20,7 +20,7 @@ test.describe('Spec editor toolbar', () => {
// The Generate dropdown exposes the Collection option
await page.getByRole('button', { name: 'Generate' }).click();
await expect.soft(page.getByRole('menuitemradio', { name: 'Collection' })).toBeVisible();
await expect.soft(page.getByRole('menuitemradio', { name: 'Requests' })).toBeVisible();
await page.keyboard.press('Escape');
// The format dropdown converts the spec between YAML and JSON
@@ -56,11 +56,13 @@ test.describe('Spec editor toolbar', () => {
await expect.soft(page.getByRole('button', { name: /error/ })).toBeVisible();
await page.getByRole('button', { name: 'Generate' }).click();
const collectionItem = page.getByRole('menuitemradio', { name: 'Collection' });
const collectionItem = page.getByRole('menuitemradio', { name: 'Requests' });
await expect.soft(collectionItem).toBeDisabled();
await collectionItem.hover();
await expect
.soft(page.getByRole('tooltip'))
.toHaveText('You cannot generate a collection when spec errors exist. Fix the errors or change the ruleset first.');
.toHaveText(
'You cannot generate an API collection when spec errors exist. Fix the errors or change the ruleset first.',
);
});
});
@@ -131,13 +131,13 @@ test('URL bar: switching environment refreshes the rendered preview', async ({ p
const tag = page.locator(TAG_SEL).first();
// ExampleA -> subenvA0
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('option', { name: 'ExampleA' }).press('Enter');
await page.getByRole('option', { name: 'ExampleA' }).press('Escape');
await expect.soft(tag).toHaveAttribute('title', /subenvA0/);
// ExampleB -> subenvB0 (preview must refresh on switch)
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByLabel('Select an API Collection Environment').click();
await page.getByRole('option', { name: 'ExampleB' }).press('Enter');
await page.getByRole('option', { name: 'ExampleB' }).press('Escape');
await expect.soft(tag).toHaveAttribute('title', /subenvB0/);
+1 -1
View File
@@ -2,7 +2,7 @@
"private": true,
"name": "insomnia-testing",
"license": "Apache-2.0",
"version": "13.2.0",
"version": "13.3.0",
"author": "Kong <office@konghq.com>",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "insomnia-vcs",
"version": "13.1.0",
"version": "13.3.0",
"private": true,
"description": "Insomnia VCS functionalities",
"license": "Apache-2.0",
@@ -0,0 +1,96 @@
import { createBuilder } from '@develohpanda/fluent-builder';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { projectSchema } from '../__schemas__/type-schemas';
import { getBackendProjectById, hasBackendProjectForRootDocument, storeBackendProject } from '../backend-projects';
import { configureStore } from '../store/current-store';
import MemoryDriver from '../store/drivers/memory-driver';
import type { BackendProject } from '../types';
const projectBuilder = createBuilder(projectSchema);
describe('hasBackendProjectForRootDocument', () => {
let driver: MemoryDriver;
let backendProject: BackendProject;
beforeEach(async () => {
backendProject = projectBuilder.reset().build();
driver = new MemoryDriver();
configureStore(driver);
driver.setItem('/projects/', Buffer.from(JSON.stringify([backendProject])));
driver.setItem(`/projects/${backendProject.id}/`, Buffer.from(''));
driver.setItem(`/projects/${backendProject.id}/meta.json`, Buffer.from(JSON.stringify(backendProject)));
});
it('should return true if has project', async () => {
const hasProject = await hasBackendProjectForRootDocument(backendProject.rootDocumentId);
expect(hasProject).toBe(true);
});
it('should return false if has no project', async () => {
const hasProject = await hasBackendProjectForRootDocument('some other id');
expect(hasProject).toBe(false);
});
});
describe('storeBackendProject()', () => {
let driver: MemoryDriver;
let backendProject: BackendProject;
beforeEach(() => {
driver = new MemoryDriver();
configureStore(driver);
backendProject = projectBuilder.reset().build();
});
it('writes backend project metadata when missing', async () => {
const setItemSpy = vi.spyOn(driver, 'setItem');
await storeBackendProject(backendProject);
expect(setItemSpy).toHaveBeenCalledTimes(1);
expect(setItemSpy).toHaveBeenCalledWith(`/projects/${backendProject.id}/meta.json`, expect.any(Buffer));
expect(await getBackendProjectById(backendProject.id)).toEqual(backendProject);
});
it('skips identical backend project metadata writes', async () => {
const setItemSpy = vi.spyOn(driver, 'setItem');
await storeBackendProject(backendProject);
setItemSpy.mockClear();
await storeBackendProject(backendProject);
expect(setItemSpy).not.toHaveBeenCalled();
});
it('writes changed backend project metadata', async () => {
const setItemSpy = vi.spyOn(driver, 'setItem');
const updatedProject = {
...backendProject,
name: `${backendProject.name} Updated`,
};
await storeBackendProject(backendProject);
setItemSpy.mockClear();
await storeBackendProject(updatedProject);
expect(setItemSpy).toHaveBeenCalledTimes(1);
expect(await getBackendProjectById(backendProject.id)).toEqual(updatedProject);
});
it('writes backend project metadata when existing meta.json is corrupted', async () => {
await driver.setItem(`/projects/${backendProject.id}/meta.json`, Buffer.from('{', 'utf8'));
const setItemSpy = vi.spyOn(driver, 'setItem');
await storeBackendProject(backendProject);
expect(setItemSpy).toHaveBeenCalledTimes(1);
expect(setItemSpy).toHaveBeenCalledWith(`/projects/${backendProject.id}/meta.json`, expect.any(Buffer));
});
});
@@ -1,26 +1,25 @@
// @ts-nocheck
import { createBuilder } from '@develohpanda/fluent-builder';
import type { BackendProject } from 'insomnia-data';
import { models } from 'insomnia-data';
import { deterministicStringify } from 'insomnia-data/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { baseModelSchema, workspaceModelSchema } from '../__schemas__/model-schemas';
import { projectSchema } from '../__schemas__/type-schemas';
import { configureStore } from '../store/current-store';
import MemoryDriver from '../store/drivers/memory-driver';
import { hashDocument } from '../util';
import { chunkArray, VCS } from '../vcs';
const baseModelBuilder = createBuilder(baseModelSchema);
const workspaceModelBuilder = createBuilder(workspaceModelSchema);
const projectBuilder = createBuilder(projectSchema);
function newDoc(id) {
return baseModelBuilder.reset()._id(id).build();
}
async function vcs(branch) {
const v = new VCS({ driver: new MemoryDriver() });
configureStore(new MemoryDriver());
const v = new VCS({ workspaceId: 'workspace_1' });
await v.switchAndCreateBackendProjectIfNotExist('workspace_1', 'Test Workspace');
await v.checkout([], branch);
return v;
@@ -971,93 +970,6 @@ describe('VCS', () => {
});
});
describe('hasBackendProjectForRootDocument', () => {
let vcs: VCS;
let backendProject: BackendProject;
beforeEach(async () => {
backendProject = projectBuilder.reset().build();
const driver = new MemoryDriver();
vcs = new VCS({ driver });
driver.setItem('/projects/', Buffer.from(JSON.stringify([backendProject])));
driver.setItem(`/projects/${backendProject.id}/`, Buffer.from(''));
driver.setItem(`/projects/${backendProject.id}/meta.json`, Buffer.from(JSON.stringify(backendProject)));
});
it('should return true if has project', async () => {
const hasProject = await vcs.hasBackendProjectForRootDocument(backendProject.rootDocumentId);
expect(hasProject).toBe(true);
});
it('should return false if has no project', async () => {
const hasProject = await vcs.hasBackendProjectForRootDocument('some other id');
expect(hasProject).toBe(false);
});
});
describe('_storeBackendProject()', () => {
let driver: MemoryDriver;
let vcs: VCS;
let backendProject: BackendProject;
beforeEach(() => {
driver = new MemoryDriver();
vcs = new VCS({ driver });
backendProject = projectBuilder.reset().build();
});
it('writes backend project metadata when missing', async () => {
const setItemSpy = vi.spyOn(driver, 'setItem');
await vcs._storeBackendProject(backendProject);
expect(setItemSpy).toHaveBeenCalledTimes(1);
expect(setItemSpy).toHaveBeenCalledWith(`/projects/${backendProject.id}/meta.json`, expect.any(Buffer));
expect(await vcs._getBackendProjectById(backendProject.id)).toEqual(backendProject);
});
it('skips identical backend project metadata writes', async () => {
const setItemSpy = vi.spyOn(driver, 'setItem');
await vcs._storeBackendProject(backendProject);
setItemSpy.mockClear();
await vcs._storeBackendProject(backendProject);
expect(setItemSpy).not.toHaveBeenCalled();
});
it('writes changed backend project metadata', async () => {
const setItemSpy = vi.spyOn(driver, 'setItem');
const updatedProject = {
...backendProject,
name: `${backendProject.name} Updated`,
};
await vcs._storeBackendProject(backendProject);
setItemSpy.mockClear();
await vcs._storeBackendProject(updatedProject);
expect(setItemSpy).toHaveBeenCalledTimes(1);
expect(await vcs._getBackendProjectById(backendProject.id)).toEqual(updatedProject);
});
it('writes backend project metadata when existing meta.json is corrupted', async () => {
await driver.setItem(`/projects/${backendProject.id}/meta.json`, Buffer.from('{', 'utf8'));
const setItemSpy = vi.spyOn(driver, 'setItem');
await vcs._storeBackendProject(backendProject);
expect(setItemSpy).toHaveBeenCalledTimes(1);
expect(setItemSpy).toHaveBeenCalledWith(`/projects/${backendProject.id}/meta.json`, expect.any(Buffer));
});
});
it('validate branch names', async () => {
expect(VCS.validateBranchName('branchA')).toEqual('');
expect(VCS.validateBranchName('feat/branch-A')).toEqual('');
@@ -0,0 +1,234 @@
import path from 'node:path';
import { deterministicStringify, generateId } from 'insomnia-data/common';
import { runGraphQL } from './session';
import { getStore } from './store/current-store';
import type { BackendProject, BackendProjectWithTeams, BackendProjectWithTeamsAndTeamProjectId, Branch } from './types';
// These helpers never read a VCS instance's active `_backendProject` — they operate on an
// explicit `rootDocumentId`/`projectId`/`project`, or scan every local project. Keeping them
// outside the `VCS` class means they can be called (and the shared store touched) without ever
// activating a workspace-scoped VCS instance.
export async function getBackendProjectById(projectId: string): Promise<BackendProject | null> {
return getStore().getItem(`/projects/${projectId}/meta.json`);
}
export async function storeBackendProject(project: BackendProject) {
let existingProject: BackendProject | null = null;
try {
existingProject = await getBackendProjectById(project.id);
} catch (err) {
console.warn('[sync] Failed to get existing backend project %s', project.id, err);
}
if (existingProject && deterministicStringify(existingProject) === deterministicStringify(project)) {
console.debug('[sync] Skipping store due to no backend changes');
return;
}
return getStore().setItem(`/projects/${project.id}/meta.json`, project);
}
export async function removeProject(project: BackendProject) {
console.log(`[sync] Remove local project ${project.id}`);
return getStore().removeItem(`/projects/${project.id}/meta.json`);
}
export async function localBackendProjects(): Promise<BackendProject[]> {
const backendProjects: BackendProject[] = [];
const keys = await getStore().keys('/projects/', false);
for (const key of keys) {
const id = path.basename(key);
const p = await getBackendProjectById(id);
if (p === null) {
// Folder exists but project meta file is gone
continue;
}
backendProjects.push(p);
}
return backendProjects;
}
export async function getBranches(backendProjectId: string): Promise<Branch[]> {
const branches: Branch[] = [];
for (const p of await getStore().keys(`/projects/${backendProjectId}/branches/`)) {
const b = await getStore().getItem(p);
if (b === null) {
// Should never happen
throw new Error(`Failed to get branch path=${p}`);
}
branches.push(b);
}
return branches;
}
export async function getBackendProjectByRootDocument(rootDocumentId: string): Promise<BackendProject | null> {
if (!rootDocumentId) {
throw new Error('No root document ID supplied for backend project');
}
// First, try finding the project
const backendProjects = await localBackendProjects();
let matchedBackendProjects = backendProjects.filter(p => p.rootDocumentId === rootDocumentId);
// If there is more than one project for root, try pruning unused ones by branch activity
if (matchedBackendProjects.length > 1) {
for (const p of matchedBackendProjects) {
const branches = await getBranches(p.id);
if (!branches.find(b => b.snapshots.length > 0)) {
await removeProject(p);
matchedBackendProjects = matchedBackendProjects.filter(({ id }) => id !== p.id);
console.log(`[sync] Remove inactive project for root ${rootDocumentId}`);
}
}
}
// If there are still too many, error out
if (matchedBackendProjects.length > 1) {
console.log('[sync] Multiple backend projects matched for root', {
backendProjects,
matchedBackendProjects,
rootDocumentId,
});
throw new Error('More than one backend project matched query');
}
return matchedBackendProjects[0] || null;
}
export async function getOrCreateBackendProjectByRootDocument(
rootDocumentId: string,
name: string,
): Promise<BackendProject> {
let project = await getBackendProjectByRootDocument(rootDocumentId);
// If we still don't have a project, create one
if (!project) {
const id = generateId('prj');
project = {
id,
name,
rootDocumentId,
};
await storeBackendProject(project);
console.log(`[sync] Created backend project ${project.id}`);
}
return project;
}
export async function hasBackendProjectForRootDocument(rootDocumentId: string): Promise<boolean> {
return Boolean(await getBackendProjectByRootDocument(rootDocumentId));
}
export async function removeBackendProjectsForRoot(rootDocumentId: string) {
const all = await localBackendProjects();
const toRemove = all.filter(p => p.rootDocumentId === rootDocumentId);
for (const backendProject of toRemove) {
await removeProject(backendProject);
}
}
// Archives a remote backend project by id, without requiring it to be pulled locally first.
export async function archiveBackendProject(backendProjectId: string) {
await runGraphQL(
`
mutation ($id: ID!) {
projectArchive(id: $id)
}
`,
{
id: backendProjectId,
},
'projectArchive',
);
console.log(`[sync] Archived remote project ${backendProjectId}`);
// A project that was never pulled has no local meta file to clean up.
const metaKey = `/projects/${backendProjectId}/meta.json`;
if (await getStore().hasItem(metaKey)) {
await getStore().removeItem(metaKey);
}
}
export async function remoteBackendProjects({ teamId, teamProjectId }: { teamId: string; teamProjectId: string }) {
console.log(`[remoteBackendProjects] Fetching remote workspaces for teamId=${teamId} teamProjectId=${teamProjectId}`);
const { projects } = await runGraphQL<{ projects: BackendProjectWithTeams[] }>(
`
query ($teamId: ID, $teamProjectId: ID) {
projects(teamId: $teamId, teamProjectId: $teamProjectId) {
id
name
rootDocumentId
teams {
id
name
}
}
}
`,
{
teamId,
teamProjectId,
},
'projects',
);
console.log(`[remoteBackendProjects] Fetched ${projects.length} remote workspaces`);
return projects.map(backend => ({
id: backend.id,
name: backend.name,
rootDocumentId: backend.rootDocumentId,
// A backend project is guaranteed to exist on exactly one team
team: backend.teams[0],
}));
}
export async function remoteBackendProjectsOfTeam({ teamId }: { teamId: string }) {
console.log(`[remoteBackendProjectsOfTeam] Fetching remote workspaces for teamId=${teamId}`);
const { projects } = await runGraphQL<{ projects: BackendProjectWithTeamsAndTeamProjectId[] }>(
`
query ($teamId: ID, $allProjects: Boolean) {
projects(teamId: $teamId, allProjects: $allProjects) {
id
name
rootDocumentId
teamProjectId
teams {
id
name
}
}
}
`,
{
teamId,
allProjects: true,
},
'projects',
);
console.log(`[remoteBackendProjectsOfTeam] Fetched ${projects.length} remote workspaces`);
return projects.map(backend => ({
id: backend.id,
name: backend.name,
rootDocumentId: backend.rootDocumentId,
teamProjectId: backend.teamProjectId,
// A backend project is guaranteed to exist on exactly one team
team: backend.teams[0],
}));
}
+4
View File
@@ -1,7 +1,11 @@
export * from './backend-projects';
export { default as FileSystemDriver } from './store/drivers/file-system-driver';
export { default as MemoryDriver } from './store/drivers/memory-driver';
export { configureStore } from './store/current-store';
export { VCS, type VcsOptions } from './vcs';
export * from './crypt';
+60
View File
@@ -0,0 +1,60 @@
import { runVcsGraphQL } from 'insomnia-api';
import type { UserSession } from 'insomnia-data';
import { services } from 'insomnia-data';
import * as crypt from './crypt';
// TODO: This is a temporary solution to get the private key from the session.
export async function getPrivateKey(sessionData?: UserSession): Promise<JsonWebKey> {
if (!sessionData) {
throw new Error("Can't get private key: session is blank.");
}
const { symmetricKey, encPrivateKey } = sessionData;
if (!symmetricKey || !encPrivateKey) {
throw new Error("Can't get private key: session is missing keys.");
}
const privateKeyStr = await crypt.decryptAES(symmetricKey, encPrivateKey);
return JSON.parse(privateKeyStr) as JsonWebKey;
}
export async function assertSession() {
const sessionData = await services.userSession.get();
const { accountId, id, publicKey, symmetricKey } = sessionData;
const privateKey = await getPrivateKey(sessionData);
if (!id) {
throw new Error('Not logged in');
}
return {
accountId,
sessionId: id,
privateKey,
publicKey,
symmetricKey,
};
}
export async function runGraphQL<T>(query: string, variables: Record<string, any>, name: string): Promise<T> {
const { sessionId } = await assertSession();
const { data, errors } = await runVcsGraphQL<T>({
query,
variables,
sessionId,
name,
});
if (errors && errors.length) {
console.log(`[sync] Failed to query ${name}`, errors);
throw new Error(`Failed to query ${name}: ${errors[0].message}`);
}
if (data == null) {
throw new Error(`Failed to query ${name}: no data returned`);
}
return data;
}
@@ -0,0 +1,17 @@
import type { BaseDriver } from './drivers/base';
import compress from './hooks/compress';
import Store from './index';
let store: Store | null = null;
export function configureStore(driver: BaseDriver): void {
store = new Store(driver, [compress]);
}
export function getStore(): Store {
if (!store) {
throw new Error('VCS store has not been configured. Call configureStore() first.');
}
return store;
}
+55 -337
View File
@@ -2,24 +2,22 @@
// - [ ] Rename things that run a fetch to fetchSomething...
// - [ ] Make sure that pull handles updating the parentId to the current project._id
import crypto from 'node:crypto';
import path from 'node:path';
import clone from 'clone';
import { runVcsGraphQL } from 'insomnia-api';
import type { BaseModel, UserSession } from 'insomnia-data';
import type { Operation } from 'insomnia-data';
import { services } from 'insomnia-data';
import { deterministicStringify, generateId } from 'insomnia-data/common';
import type { BaseModel, Operation } from 'insomnia-data';
import {
archiveBackendProject,
getBackendProjectById,
getBranches,
getOrCreateBackendProjectByRootDocument,
storeBackendProject,
} from './backend-projects';
import * as crypt from './crypt';
import Store from './store';
import type { BaseDriver } from './store/drivers/base';
import FileSystemDriver from './store/drivers/file-system-driver';
import compress from './store/hooks/compress';
import { assertSession, runGraphQL } from './session';
import { getStore } from './store/current-store';
import type {
BackendProject,
BackendProjectWithTeams,
BackendProjectWithTeamsAndTeamProjectId,
Branch,
DocumentKey,
Head,
@@ -85,19 +83,11 @@ const generateAES256KeyInNode = async (): Promise<JsonWebKey> => {
};
};
export type VcsOptions = {
export interface VcsOptions {
workspaceId: string;
conflictHandler?: ConflictHandler;
testMode?: boolean;
} & (
| {
driver?: undefined;
dataPath: string;
}
| {
driver: BaseDriver;
dataPath?: undefined;
}
);
}
// Stage/Unstage
// Staged items are about to be committed
@@ -114,17 +104,15 @@ export class VCS {
return '';
}
_store: Store;
_driver: BaseDriver;
readonly workspaceId: string;
// stored by key `/projects/${project.id}/meta.json`
_backendProject: BackendProject | null;
_conflictHandler?: ConflictHandler | null;
_testMode: boolean | undefined;
_stageByBackendProjectId: Record<string, Stage> = {};
constructor({ driver, dataPath, conflictHandler, testMode }: VcsOptions) {
this._driver = driver || FileSystemDriver.create(dataPath!);
this._store = new Store(this._driver, [compress]);
constructor({ workspaceId, conflictHandler, testMode }: VcsOptions) {
this.workspaceId = workspaceId;
this._testMode = testMode;
this._conflictHandler = conflictHandler;
// To be set later
@@ -135,41 +123,15 @@ export class VCS {
this._backendProject = backendProject;
console.debug(`[sync] Activated project ${backendProject.id}`);
// Store it because it might not be yet
await this._storeBackendProject(backendProject);
await storeBackendProject(backendProject);
}
hasBackendProject() {
return this._backendProject !== null;
}
async hasBackendProjectForRootDocument(rootDocumentId: string) {
return Boolean(await this._getBackendProjectByRootDocument(rootDocumentId));
}
async removeBackendProjectsForRoot(rootDocumentId: string) {
const all = await this._allBackendProjects();
const toRemove = all.filter(p => p.rootDocumentId === rootDocumentId);
for (const backendProject of toRemove) {
await this._removeProject(backendProject);
}
}
async archiveProject() {
const backendProjectId = this._backendProjectId();
await this._runGraphQL(
`
mutation ($id: ID!) {
projectArchive(id: $id)
}
`,
{
id: backendProjectId,
},
'projectArchive',
);
console.log(`[sync] Archived remote project ${backendProjectId}`);
await this._store.removeItem(`/projects/${backendProjectId}/meta.json`);
await archiveBackendProject(this._backendProjectId());
this._backendProject = null;
}
@@ -178,87 +140,10 @@ export class VCS {
}
async switchAndCreateBackendProjectIfNotExist(rootDocumentId: string, name: string) {
const project = await this._getOrCreateBackendProjectByRootDocument(rootDocumentId, name);
const project = await getOrCreateBackendProjectByRootDocument(rootDocumentId, name);
await this.setBackendProject(project);
}
async localBackendProjects() {
return this._allBackendProjects();
}
async remoteBackendProjects({ teamId, teamProjectId }: { teamId: string; teamProjectId: string }) {
console.log(
`[remoteBackendProjects] Fetching remote workspaces for teamId=${teamId} teamProjectId=${teamProjectId}`,
);
const { projects } = await this._runGraphQL<{ projects: BackendProjectWithTeams[] }>(
`
query ($teamId: ID, $teamProjectId: ID) {
projects(teamId: $teamId, teamProjectId: $teamProjectId) {
id
name
rootDocumentId
teams {
id
name
}
}
}
`,
{
teamId,
teamProjectId,
},
'projects',
);
console.log(`[remoteBackendProjects] Fetched ${projects.length} remote workspaces`);
return projects.map(backend => ({
id: backend.id,
name: backend.name,
rootDocumentId: backend.rootDocumentId,
// A backend project is guaranteed to exist on exactly one team
team: backend.teams[0],
}));
}
async remoteBackendProjectsOfTeam({ teamId }: { teamId: string }) {
console.log(`[remoteBackendProjectsOfTeam] Fetching remote workspaces for teamId=${teamId}`);
const { projects } = await this._runGraphQL<{ projects: BackendProjectWithTeamsAndTeamProjectId[] }>(
`
query ($teamId: ID, $allProjects: Boolean) {
projects(teamId: $teamId, allProjects: $allProjects) {
id
name
rootDocumentId
teamProjectId
teams {
id
name
}
}
}
`,
{
teamId,
allProjects: true,
},
'projects',
);
console.log(`[remoteBackendProjectsOfTeam] Fetched ${projects.length} remote workspaces`);
return projects.map(backend => ({
id: backend.id,
name: backend.name,
rootDocumentId: backend.rootDocumentId,
teamProjectId: backend.teamProjectId,
// A backend project is guaranteed to exist on exactly one team
team: backend.teams[0],
}));
}
async blobFromLastSnapshot(key: string) {
const branch = await this._getCurrentBranch();
const snapshot = await this._getLatestSnapshot(branch.name);
@@ -572,6 +457,10 @@ export class VCS {
throw new Error(`Failed to find commit by id ${snapshotId}`);
}
// Update branch.modified so getVersion() changes after a rollback, so that editors with historyKey can detect working content change.
const branch = await this._getCurrentBranch();
await this._storeBranch(branch);
const currentState: SnapshotState = candidates.map(candidate => ({
key: candidate.key,
blob: hashDocument(candidate.document).hash,
@@ -632,7 +521,7 @@ export class VCS {
}
async getRemoteBranchNames(): Promise<string[]> {
const { branches } = await this._runGraphQL<{ branches: { name: string }[] | null }>(
const { branches } = await runGraphQL<{ branches: { name: string }[] | null }>(
`
query ($projectId: ID!) {
branches(project: $projectId) {
@@ -649,7 +538,7 @@ export class VCS {
}
async getBranchNames(): Promise<string[]> {
const branches = await this._getBranches();
const branches = await getBranches(this._backendProjectId());
return branches.map(b => b.name);
}
@@ -731,7 +620,7 @@ export class VCS {
remoteProject = await this._createRemoteProject({ ...localProject, teamId, teamProjectId });
}
await this._storeBackendProject(remoteProject);
await storeBackendProject(remoteProject);
return remoteProject;
}
@@ -955,29 +844,8 @@ export class VCS {
return snapshot;
}
async _runGraphQL<T>(query: string, variables: Record<string, any>, name: string): Promise<T> {
const { sessionId } = await this._assertSession();
const { data, errors } = await runVcsGraphQL<T>({
query,
variables,
sessionId,
name,
});
if (errors && errors.length) {
console.log(`[sync] Failed to query ${name}`, errors);
throw new Error(`Failed to query ${name}: ${errors[0].message}`);
}
if (data == null) {
throw new Error(`Failed to query ${name}: no data returned`);
}
return data;
}
async _queryBlobsMissing(ids: string[]): Promise<string[]> {
const { blobsMissing } = await this._runGraphQL<{ blobsMissing: { missing: string[] } }>(
const { blobsMissing } = await runGraphQL<{ blobsMissing: { missing: string[] } }>(
`
query ($projectId: ID!, $ids: [ID!]!) {
blobsMissing(project: $projectId, ids: $ids) {
@@ -995,7 +863,7 @@ export class VCS {
}
async _queryRemoveBranch(branchName: string) {
await this._runGraphQL(
await runGraphQL(
`
mutation ($projectId: ID!, $branch: String!) {
branchRemove(project: $projectId, name: $branch)
@@ -1009,7 +877,7 @@ export class VCS {
}
async _queryBranch(branchName: string): Promise<Branch | null> {
const { branch } = await this._runGraphQL<{ branch: Branch | null }>(
const { branch } = await runGraphQL<{ branch: Branch | null }>(
`
query ($projectId: ID!, $branch: String!) {
branch(project: $projectId, name: $branch) {
@@ -1032,7 +900,7 @@ export class VCS {
let allSnapshots: Snapshot[] = [];
for (const ids of chunkArray(allIds, 20)) {
const { snapshots } = await this._runGraphQL<{ snapshots: Snapshot[] }>(
const { snapshots } = await runGraphQL<{ snapshots: Snapshot[] }>(
`
query ($ids: [ID!]!, $projectId: ID!) {
snapshots(ids: $ids, project: $projectId) {
@@ -1068,7 +936,7 @@ export class VCS {
// push snapshots to the backend's current branch
async _queryPushSnapshots(allSnapshots: Snapshot[]) {
const { accountId } = await this._assertSession();
const { accountId } = await assertSession();
for (const snapshots of chunkArray(allSnapshots, 20)) {
// This bit of logic fills in any missing author IDs from times where
@@ -1080,7 +948,7 @@ export class VCS {
}
const branch = await this._getCurrentBranch();
const { snapshotsCreate } = await this._runGraphQL<{ snapshotsCreate: Snapshot[] }>(
const { snapshotsCreate } = await runGraphQL<{ snapshotsCreate: Snapshot[] }>(
`
mutation ($projectId: ID!, $snapshots: [SnapshotInput!]!, $branchName: String!) {
snapshotsCreate(project: $projectId, snapshots: $snapshots, branch: $branchName) {
@@ -1129,7 +997,7 @@ export class VCS {
const result: Record<string, Buffer> = {};
for (const ids of chunkArray(allIds, 50)) {
const { blobs } = await this._runGraphQL<{ blobs: { id: string; content: string }[] }>(
const { blobs } = await runGraphQL<{ blobs: { id: string; content: string }[] }>(
`
query ($ids: [ID!]!, $projectId: ID!) {
blobs(ids: $ids, project: $projectId) {
@@ -1167,7 +1035,7 @@ export class VCS {
id: i.id,
content: i.content,
}));
const { blobsCreate } = await this._runGraphQL<{ blobsCreate: { count: number } }>(
const { blobsCreate } = await runGraphQL<{ blobsCreate: { count: number } }>(
`
mutation ($projectId: ID!, $blobs: [BlobInput!]!) {
blobsCreate(project: $projectId, blobs: $blobs) {
@@ -1221,7 +1089,7 @@ export class VCS {
}
async _queryBackendProjectKey() {
const { projectKey } = await this._runGraphQL<{ projectKey: { encSymmetricKey: string } }>(
const { projectKey } = await runGraphQL<{ projectKey: { encSymmetricKey: string } }>(
`
query ($projectId: ID!) {
projectKey(projectId: $projectId) {
@@ -1238,7 +1106,7 @@ export class VCS {
}
async _queryProject(): Promise<BackendProject | null> {
const { project } = await this._runGraphQL<{ project: BackendProject | null }>(
const { project } = await runGraphQL<{ project: BackendProject | null }>(
`
query ($id: ID!) {
project(id: $id) {
@@ -1267,7 +1135,7 @@ export class VCS {
teamId,
});
const { teamMemberKeys } = await this._runGraphQL<{
const { teamMemberKeys } = await runGraphQL<{
teamMemberKeys: {
memberKeys: {
accountId: string;
@@ -1328,7 +1196,7 @@ export class VCS {
// Use the local project ID if available; fall back to the workspace ID as a stable identifier.
const localProjectId = this._backendProject ? this._backendProject.id : workspaceId;
const { projectCreate } = await this._runGraphQL<{ projectCreate: BackendProject }>(
const { projectCreate } = await runGraphQL<{ projectCreate: BackendProject }>(
`
mutation (
$name: String!,
@@ -1369,15 +1237,11 @@ export class VCS {
async _getBackendProject(): Promise<BackendProject | null> {
const projectId = this._backendProject ? this._backendProject.id : 'n/a';
return this._getBackendProjectById(projectId);
}
async _getBackendProjectById(projectId: string): Promise<BackendProject | null> {
return this._store.getItem(`/projects/${projectId}/meta.json`);
return getBackendProjectById(projectId);
}
async _getBackendProjectSymmetricKey() {
const { privateKey, symmetricKey } = await this._assertSession();
const { privateKey, symmetricKey } = await assertSession();
if (this._testMode) {
// use the session symmetric key in playwright tests
@@ -1389,22 +1253,6 @@ export class VCS {
return JSON.parse(symmetricKeyStr);
}
// TODO: This is a temporary solution to get the private key from the session.
async _getPrivateKey(sessionData?: UserSession): Promise<JsonWebKey> {
if (!sessionData) {
throw new Error("Can't get private key: session is blank.");
}
const { symmetricKey, encPrivateKey } = sessionData;
if (!symmetricKey || !encPrivateKey) {
throw new Error("Can't get private key: session is missing keys.");
}
const privateKeyStr = await crypt.decryptAES(symmetricKey, encPrivateKey);
return JSON.parse(privateKeyStr) as JsonWebKey;
}
async _assertBackendProject() {
const project = await this._getBackendProject();
@@ -1415,24 +1263,8 @@ export class VCS {
return project;
}
async _storeBackendProject(project: BackendProject) {
let existingProject: BackendProject | null = null;
try {
existingProject = await this._getBackendProjectById(project.id);
} catch (err) {
console.warn('[sync] Failed to get existing backend project %s', project.id, err);
}
if (existingProject && deterministicStringify(existingProject) === deterministicStringify(project)) {
console.debug('[sync] Skipping store due to no backend changes');
return;
}
return this._store.setItem(`/projects/${project.id}/meta.json`, project);
}
async _getHead(): Promise<Head> {
const head = await this._store.getItem(`/projects/${this._backendProjectId()}/head.json`);
const head = await getStore().getItem(`/projects/${this._backendProjectId()}/head.json`);
if (head === null) {
await this._storeHead({ branch: 'master' });
@@ -1447,24 +1279,6 @@ export class VCS {
return this._getOrCreateBranch(head.branch);
}
async _assertSession() {
const sessionData = await services.userSession.get();
const { accountId, id, publicKey, symmetricKey } = sessionData;
const privateKey = await this._getPrivateKey(sessionData);
if (!id) {
throw new Error('Not logged in');
}
return {
accountId,
sessionId: id,
privateKey,
publicKey,
symmetricKey,
};
}
async _assertBranch(branchName: string) {
const branch = await this._getBranch(branchName);
@@ -1484,26 +1298,7 @@ export class VCS {
}
async _getBranch(name: string): Promise<Branch | null> {
return this._store.getItem(`/projects/${this._backendProjectId()}/branches/${encodeBranchName(name)}.json`);
}
async _getBranches(backendProjectId?: string) {
const branches: Branch[] = [];
const pId = backendProjectId || this._backendProjectId();
for (const p of await this._store.keys(`/projects/${pId}/branches/`)) {
const b = await this._store.getItem(p);
if (b === null) {
// Should never happen
throw new Error(`Failed to get branch path=${p}`);
}
branches.push(b);
}
return branches;
return getStore().getItem(`/projects/${this._backendProjectId()}/branches/${encodeBranchName(name)}.json`);
}
async _getOrCreateBranch(name: string): Promise<Branch> {
@@ -1526,80 +1321,8 @@ export class VCS {
return branch;
}
async _getBackendProjectByRootDocument(rootDocumentId: string) {
if (!rootDocumentId) {
throw new Error('No root document ID supplied for backend project');
}
// First, try finding the project
const backendProjects = await this._allBackendProjects();
let matchedBackendProjects = backendProjects.filter(p => p.rootDocumentId === rootDocumentId);
// If there is more than one project for root, try pruning unused ones by branch activity
if (matchedBackendProjects.length > 1) {
for (const p of matchedBackendProjects) {
const branches = await this._getBranches(p.id);
if (!branches.find(b => b.snapshots.length > 0)) {
await this._removeProject(p);
matchedBackendProjects = matchedBackendProjects.filter(({ id }) => id !== p.id);
console.log(`[sync] Remove inactive project for root ${rootDocumentId}`);
}
}
}
// If there are still too many, error out
if (matchedBackendProjects.length > 1) {
console.log('[sync] Multiple backend projects matched for root', {
backendProjects,
matchedBackendProjects,
rootDocumentId,
});
throw new Error('More than one backend project matched query');
}
return matchedBackendProjects[0] || null;
}
async _getOrCreateBackendProjectByRootDocument(rootDocumentId: string, name: string) {
let project: BackendProject | null = await this._getBackendProjectByRootDocument(rootDocumentId);
// If we still don't have a project, create one
if (!project) {
const id = generateId('prj');
project = {
id,
name,
rootDocumentId,
};
await this._storeBackendProject(project);
console.log(`[sync] Created backend project ${project.id}`);
}
return project;
}
async _allBackendProjects() {
const backendProjects: BackendProject[] = [];
const keys = await this._store.keys('/projects/', false);
for (const key of keys) {
const id = path.basename(key);
const p: BackendProject | null = await this._getBackendProjectById(id);
if (p === null) {
// Folder exists but project meta file is gone
continue;
}
backendProjects.push(p);
}
return backendProjects;
}
async _assertSnapshot(id: string) {
const snapshot: Snapshot | null = await this._store.getItem(
const snapshot: Snapshot | null = await getStore().getItem(
`/projects/${this._backendProjectId()}/snapshots/${id}.json`,
);
@@ -1615,7 +1338,7 @@ export class VCS {
}
async _getSnapshot(id: string) {
const snapshot: Snapshot | null = await this._store.getItem(
const snapshot: Snapshot | null = await getStore().getItem(
`/projects/${this._backendProjectId()}/snapshots/${id}.json`,
);
@@ -1634,7 +1357,7 @@ export class VCS {
}
async _storeSnapshot(snapshot: Snapshot) {
return this._store.setItem(`/projects/${this._backendProjectId()}/snapshots/${snapshot.id}.json`, snapshot);
return getStore().setItem(`/projects/${this._backendProjectId()}/snapshots/${snapshot.id}.json`, snapshot);
}
async _storeSnapshots(snapshots: Snapshot[]) {
@@ -1642,7 +1365,7 @@ export class VCS {
for (const snapshot of snapshots) {
const p = `/projects/${this._backendProjectId()}/snapshots/${snapshot.id}.json`;
const promise = this._store.setItem(p, snapshot);
const promise = getStore().setItem(p, snapshot);
// @ts-expect-error -- TSCONVERSION appears to be a genuine error
promises.push(promise);
}
@@ -1659,29 +1382,24 @@ export class VCS {
branch.modified = new Date();
// toLowerCase may introduce issues under case sensitive filesystems
return this._store.setItem(
return getStore().setItem(
`/projects/${this._backendProjectId()}/branches/${encodeBranchName(branch.name).toLowerCase()}.json`,
branch,
);
}
async _removeBranch(branch: Branch) {
return this._store.removeItem(
return getStore().removeItem(
`/projects/${this._backendProjectId()}/branches/${encodeBranchName(branch.name)}.json`,
);
}
async _removeProject(project: BackendProject) {
console.log(`[sync] Remove local project ${project.id}`);
return this._store.removeItem(`/projects/${project.id}/meta.json`);
}
async _storeHead(head: Head) {
await this._store.setItem(`/projects/${this._backendProjectId()}/head.json`, head);
await getStore().setItem(`/projects/${this._backendProjectId()}/head.json`, head);
}
_getBlob(blobId: string) {
return this._store.getItem(
return getStore().getItem(
`/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`,
) as Promise<BaseModel | null>;
}
@@ -1697,7 +1415,7 @@ export class VCS {
}
async _storeBlob(blobId: string, content: Record<string, any> | null) {
return this._store.setItem(
return getStore().setItem(
`/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`,
content,
);
@@ -1709,7 +1427,7 @@ export class VCS {
for (const blobId of Object.keys(blobsToStore)) {
const buff = Buffer.from(blobsToStore[blobId], 'utf8');
promises.push(
this._store.setItem(
getStore().setItem(
`/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`,
buff,
),
@@ -1724,7 +1442,7 @@ export class VCS {
for (const blobId of Object.keys(decryptedBlobs)) {
promises.push(
this._store.setItemRaw(
getStore().setItemRaw(
`/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`,
decryptedBlobs[blobId],
),
@@ -1735,13 +1453,13 @@ export class VCS {
}
async _getBlobRaw(blobId: string) {
return this._store.getItemRaw(
return getStore().getItemRaw(
`/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`,
);
}
async _hasBlob(blobId: string) {
return this._store.hasItem(`/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`);
return getStore().hasItem(`/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`);
}
getActiveBackendProject() {
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "insomnia",
"version": "13.2.0",
"version": "13.3.0",
"productName": "Insomnia",
"private": true,
"description": "The Collaborative API Design Tool",
@@ -68,9 +68,9 @@
"@react-router/fs-routes": "7.18.2",
"@react-router/node": "7.18.2",
"@react-router/serve": "7.18.2",
"@rjsf/core": "6.0.0-beta.15",
"@rjsf/utils": "6.0.0-beta.15",
"@rjsf/validator-ajv8": "6.0.0-beta.15",
"@rjsf/core": "6.0.0-beta.19",
"@rjsf/utils": "6.0.0-beta.19",
"@rjsf/validator-ajv8": "6.0.0-beta.19",
"@sentry/electron": "^6.5.0",
"@stoplight/spectral-core": "^1.22.0",
"@stoplight/spectral-formats": "^1.8.2",
@@ -220,7 +220,7 @@
},
"optionalDependencies": {
"@kong/insomnia-plugin-ai": "^1.0.11",
"@kong/insomnia-plugin-external-vault": "0.1.5-dev.20260806050036"
"@kong/insomnia-plugin-external-vault": "0.1.5"
},
"dev": {
"dev-server-port": 3334
@@ -0,0 +1,113 @@
import type * as fsPromisesTypes from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/mock/user/data/path'),
getAppPath: vi.fn(() => (globalThis as any).__yarnHome),
},
}));
vi.mock('../main/analytics', () => ({
trackAnalyticsEvent: vi.fn(),
AnalyticsEvent: {
installPlugin: 'Plugin Installed',
},
}));
vi.mock('insomnia-data', () => ({
services: {
settings: {
get: vi.fn(() => Promise.resolve({})),
},
},
}));
const execFileMock = vi.fn();
vi.mock('node:child_process', async () => {
// Mirror Node semantics: promisified execFile resolves { stdout, stderr }.
const { promisify } = await import('node:util');
const fn: any = (...args: any[]) => execFileMock(...args);
fn[promisify.custom] = (cmd: any, args: any, opts: any) =>
new Promise((resolve, reject) => {
execFileMock(cmd, args, opts, (err: any, stdout: any, stderr: any) =>
err ? reject(err) : resolve({ stdout, stderr }),
);
});
return { execFile: fn };
});
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof fsPromisesTypes>('node:fs/promises');
return {
...actual,
writeFile: (async (file: any, data: any, ...rest: any[]) => {
const store = ((globalThis as any).__writtenManifests ??= {}) as Record<string, string>;
store[String(file)] = String(data);
return actual.writeFile(file, data, ...rest);
}) as any,
};
});
import * as fsPromises from 'node:fs/promises';
import { getPluginInfo } from '../main/install-plugin';
const pluginPayload = {
data: {
name: 'insomnia-plugin-test',
version: '1.0.0',
insomnia: {
name: 'Test Plugin',
displayName: 'Test Plugin',
description: 'A test plugin',
},
dist: {
shasum: 'abc123',
tarball: 'https://registry.npmjs.org/test-plugin/-/test-plugin-1.0.0.tgz',
},
},
};
describe('getPluginInfo working directory', () => {
const testDir = path.dirname(fileURLToPath(import.meta.url));
// getYarnPath only accepts app paths under the module base (src/), so
// stage an empty standalone bundle inside the test dir for the lookup.
const yarnHome = path.resolve(testDir, 'tmp-yarn-home');
afterEach(async () => {
await fsPromises.rm(yarnHome, { recursive: true, force: true });
});
it('runs yarn info in a controlled temp dir, not the process cwd', async () => {
(globalThis as any).__writtenManifests = {};
// Production layout resolves the bundle to ../bin relative to the app
// path, so point the app one level below the staged bin directory.
(globalThis as any).__yarnHome = path.resolve(yarnHome, 'app');
await fsPromises.mkdir(path.resolve(yarnHome, 'bin'), { recursive: true });
await fsPromises.writeFile(path.resolve(yarnHome, 'bin', 'yarn-standalone.js'), '', 'utf8');
execFileMock.mockImplementation((_cmd: any, _args: any, _options: any, callback: any) => {
callback?.(null, JSON.stringify(pluginPayload), '');
return {} as any;
});
const result = await getPluginInfo('insomnia-plugin-test');
expect(result.name).toBe('insomnia-plugin-test');
expect(execFileMock).toHaveBeenCalledOnce();
const options = execFileMock.mock.calls[0][2] as { cwd?: string };
expect(options.cwd).toBeDefined();
expect(options.cwd).not.toBe(process.cwd());
expect(options.cwd?.startsWith(tmpdir())).toBe(true);
const written = (globalThis as any).__writtenManifests as Record<string, string>;
const manifestKey = Object.keys(written).find(key => key.endsWith('package.json'));
expect(manifestKey).toBeDefined();
expect(manifestKey?.startsWith(options.cwd as string)).toBe(true);
expect(JSON.parse(written[manifestKey as string])).toMatchObject({ license: 'ISC' });
});
});
@@ -58,6 +58,22 @@ describe('updateAppDataOnDbChanges', () => {
expect(queryClient.getQueryState(organizationDataKeys.byOrganizationId(organizationId))?.isInvalidated).toBe(true);
});
it('invalidates both the origin and destination organization when a project moves parents', () => {
const organizationAId = 'org_a';
const organizationBId = 'org_b';
const project = { _id: 'proj_1', parentId: organizationAId } as OrganizationData['projects'][0];
const orgDataA: OrganizationData = { projects: [project], workspaces: [], workspaceMetas: [] };
const orgDataB: OrganizationData = { projects: [], workspaces: [], workspaceMetas: [] };
queryClient.setQueryData(organizationDataKeys.byOrganizationId(organizationAId), orgDataA);
queryClient.setQueryData(organizationDataKeys.byOrganizationId(organizationBId), orgDataB);
const moved = fakeDoc('Project', { _id: 'proj_1', parentId: organizationBId });
updateAppDataOnDbChanges(queryClient, [change('update', moved)]);
expect(queryClient.getQueryState(organizationDataKeys.byOrganizationId(organizationAId))?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(organizationDataKeys.byOrganizationId(organizationBId))?.isInvalidated).toBe(true);
});
it('invalidates the organization matched via project when a workspace changes', () => {
const organizationId = 'org_1';
const projectId = 'proj_1';
@@ -19,6 +19,14 @@ describe('parseDeepLinkUrl', () => {
expect(parseDeepLinkUrl('insomniadev://app/import', true)?.urlWithoutParams).toBe('insomnia://app/import');
expect(parseDeepLinkUrl('insomniadev://app/import', false)?.urlWithoutParams).toBe('insomniadev://app/import');
});
it('preserves param names verbatim', () => {
expect(parseDeepLinkUrl('insomnia://app/import?CuRl=x&operationId=listPets&sourceUrl=https://x')?.params).toEqual({
CuRl: 'x',
operationId: 'listPets',
sourceUrl: 'https://x',
});
});
});
describe('sanitizeUrlAndExtractOrigin', () => {
@@ -53,8 +61,35 @@ describe('resolveImportDeepLink', () => {
expect(resolveImportDeepLink({ mcp: 'https://x/mcp', curl: 'curl https://x' })?.type).toBe('mcp');
});
it('returns null when no resource param is present', () => {
expect(resolveImportDeepLink({ origin: 'https://app.insomnia.rest' })).toBeNull();
expect(resolveImportDeepLink({ mcp: ' ' })).toBeNull();
it('selects the tab with an empty value for a bare param', () => {
expect(resolveImportDeepLink({ clipboard: '' })).toEqual({ type: 'clipboard', defaultValue: '', origin: '' });
expect(resolveImportDeepLink({ curl: '' })).toEqual({ type: 'curl', defaultValue: '', origin: '' });
expect(resolveImportDeepLink({})).toBeNull();
});
it('prefers value-bearing params over bare ones', () => {
expect(resolveImportDeepLink({ uri: '', curl: 'curl https://x' })).toEqual({
type: 'curl',
defaultValue: 'curl https://x',
origin: '',
});
expect(resolveImportDeepLink({ curl: '', clipboard: '' })?.type).toBe('curl');
});
it('matches source selector names case-insensitively but metadata params case-sensitively', () => {
expect(resolveImportDeepLink({ CuRl: 'curl https://x', mCp: '' })?.type).toBe('curl');
expect(resolveImportDeepLink({ CLIPBOARD: '' })?.type).toBe('clipboard');
// camelCase metadata params must not be shadowed by lowercasing
expect(
resolveImportDeepLink({ uri: 'https://x/spec.yaml', operationId: 'listPets' }),
).toMatchObject({ type: 'uri', operationId: 'listPets' });
});
it('ignores a value on a valueless source', () => {
expect(resolveImportDeepLink({ clipboard: 'ignored' })).toEqual({ type: 'clipboard', defaultValue: '', origin: '' });
});
it('treats a whitespace-only value as bare', () => {
expect(resolveImportDeepLink({ mcp: ' ' })).toEqual({ type: 'mcp', defaultValue: '', origin: '' });
});
});
@@ -288,7 +288,7 @@ describe('importRaw()', () => {
const fixturePath = path.join(__dirname, '..', '__fixtures__', 'openapi', 'endpoint-security-input.yaml');
const content = fs.readFileSync(fixturePath, 'utf8').toString();
const disableLogs = console.log;
console.log = () => { };
console.log = () => {};
const scanResult = await importUtil.scanResources([
{
contentStr: content,
@@ -605,7 +605,7 @@ describe('export/import round-trip is deterministic', () => {
});
const { workspace } = await roundTrip(sourceWorkspace._id);
expect(workspace.scope).toBe('design');
expect(workspace.scope).toBe('collection');
const importedSpec = await services.apiSpec.getByParentId(workspace._id);
expect(importedSpec).toBeTruthy();
@@ -45,7 +45,7 @@ describe('Insomnia v5 Import/Export - Comprehensive Tests', () => {
it('maps v5 schema types to workspace scopes', () => {
expect(insomniaSchemaTypeToScope('collection.insomnia.rest/5.0')).toBe('collection');
expect(insomniaSchemaTypeToScope('environment.insomnia.rest/5.0')).toBe('environment');
expect(insomniaSchemaTypeToScope('spec.insomnia.rest/5.0')).toBe('design');
expect(insomniaSchemaTypeToScope('spec.insomnia.rest/5.0')).toBe('collection');
expect(insomniaSchemaTypeToScope('mock.insomnia.rest/5.0')).toBe('mock-server');
expect(insomniaSchemaTypeToScope('mcpClient.insomnia/5.0')).toBe('mcp');
});
+23
View File
@@ -39,6 +39,29 @@ export function parseApiSpec(rawDocument: string) {
return result;
}
/**
* Detects the serialization syntax of an API spec by probing it with JSON.parse.
* JSON is valid YAML, so anything that fails to parse as JSON is treated as YAML.
*/
export function detectApiSpecSyntax(contents: string): 'json' | 'yaml' {
try {
JSON.parse(contents);
return 'json';
} catch {
return 'yaml';
}
}
/**
* Re-serializes an API spec into the requested syntax. Throws if the spec cannot
* be parsed (invalid YAML or JSON).
*/
export function convertApiSpecSyntax(contents: string, to: 'json' | 'yaml'): string {
// YAML parses JSON as well
const parsed = YAML.parse(contents);
return to === 'json' ? JSON.stringify(parsed, null, 2) : YAML.stringify(parsed);
}
export function resolveComponentSchemaRefs(spec: ParsedApiSpec, methodInfo: Record<string, any>) {
const schemas = spec.contents?.components?.schemas;
if (!schemas) {
+10
View File
@@ -248,6 +248,16 @@ export function updateAppDataOnDbChanges(queryClient: QueryClient, changes: Chan
if (doc.type === models.project.type) {
organizationIdsToRevalidate.add(doc.parentId);
// The project may have moved from a different, already-cached organization (e.g. the Konnect
// migration re-parents projects across organizations) — find and revalidate its old one too,
// since `doc.parentId` alone only ever points at the new one.
for (const [queryKey, data] of queryClient.getQueriesData<OrganizationData>({
queryKey: organizationDataKeys.all,
})) {
if (data?.projects.some(p => p._id === doc._id)) {
organizationIdsToRevalidate.add(queryKey[1] as string);
}
}
continue;
}
@@ -0,0 +1,39 @@
import { models } from 'insomnia-data';
import { describe, expect, it } from 'vitest';
import type { Child } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
import { filterCollection } from './collection';
const makeChild = (doc: Record<string, unknown>): Child =>
({
doc: { type: models.request.type, ...doc },
children: [],
collapsed: false,
hidden: false,
pinned: false,
level: 0,
ancestors: [],
}) as unknown as Child;
describe('filterCollection()', () => {
it('hides docs that do not match the filter', () => {
const children = [makeChild({ _id: 'req_1', name: 'Get users', url: 'https://api.example.com' })];
expect(filterCollection(children, 'users').map(child => child.hidden)).toEqual([false]);
expect(filterCollection(children, 'missing').map(child => child.hidden)).toEqual([true]);
});
// Docs are read straight off disk (NeDB) and can carry non-string values, e.g. a folder
// `description` object imported from Postman. A filter must not take the sidebar down with it.
it('tolerates non-string searchable fields on a doc', () => {
const children = [
makeChild({ _id: 'req_1', name: 42, description: { content: 'markdown', type: 'text/markdown' }, url: 8080 }),
makeChild({ _id: 'req_2', name: 'Get users', url: 'https://api.example.com' }),
];
const filtered = filterCollection(children, 'users');
expect(filtered.map(child => child.hidden)).toEqual([true, false]);
});
});
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff. Show more