mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-05 12:33:40 -05:00
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:
commit
466ecc8ca6
298 files changed
+12432
-6807
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
Generated
+385
-1080
File diff suppressed because it is too large.
Load diff
+3
-2
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)]),
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()}`);
|
||||
|
||||
@@ -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
|
||||
@@ -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/);
|
||||
|
||||
@@ -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,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],
|
||||
}));
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user