v2 Migration: scoutprime - #31
Conversation
- Convert integration.js to TypeScript (src/integration.ts, src/queries.ts) - Replace postman-request with polarity-integration-utils - Create TypeScript type declarations (src/types/scout-prime.ts) - Convert Ember/Handlebars component to Lit web component (web-components/details.ts) - Update config.json (runtimeVersion 2, dataTypes, webComponents) - Add v2 build tooling (tsc, vite, vitest, eslint, prettier) - Remove v1 files (components/, templates/, styles/, integration.js) - Remove v1 dependencies (postman-request, lodash, async) - Bump version to 4.0.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Migrates the scoutprime integration from the v1 (Ember template + custom request plumbing) to the v2 runtime with TypeScript backend logic and Lit-based web components for the details UI.
Changes:
- Replaces the v1 Ember/HBS UI with a Lit web component (
web-components/details.ts) and updates integration config toruntimeVersion: 2. - Rewrites the integration backend in TypeScript (
src/integration.ts,src/queries.ts) usingpolarity-integration-utils. - Introduces modern build/lint/test tooling (Vite, Vitest incl. browser runner, ESLint, TS configs) and updates package metadata.
Reviewed changes
Copilot reviewed 26 out of 29 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| web-components/details.ts | New Lit details component rendering associations/collections/ownership/locations/WHOIS. |
| vitest.config.ts | Adds split server + browser Vitest projects (Playwright provider). |
| vite.config.js | Adds Vite library build for web components using Polarity ICL plugin. |
| tsconfig.web.json | Adds TS config for web-components typechecking. |
| tsconfig.json | Updates TS compilation settings for v2 TypeScript backend build to dist/. |
| test/web-components/details.test.ts | Adds initial (currently placeholder) browser test file. |
| test/integration.test.ts | Adds initial (currently placeholder) server test file. |
| templates/scprime.hbs | Removes v1 Ember template. |
| styles/scprime.less | Removes v1 LESS styling for Ember template. |
| src/types/scout-prime.ts | Adds typed response models for ScoutPrime API responses. |
| src/queries.ts | Adds TS query functions for associations/owners/whois using PolarityRequest. |
| src/queries.js | Removes v1 JS query implementation. |
| src/polarity-request.js | Removes custom v1 request wrapper. |
| src/logger.js | Removes v1 logger shim (replaced by utils logger wiring). |
| src/integration.ts | Adds v2 TS integration entry (startup/doLookup/validateOptions). |
| src/errors.js | Removes v1 custom error classes (handled by utils). |
| src/create-result-object.js | Removes v1 result builder (logic moved into src/integration.ts). |
| package.json | Updates dependencies/scripts; points main to dist/integration.js; bumps version to 4.0.0. |
| package-lock.json | Lockfile update reflecting new toolchain dependencies. |
| integration.js | Removes v1 root entrypoint JS (replaced by TS build output). |
| eslint.config.mjs | Adds ESLint + typescript-eslint + prettier config. |
| config/config.json | Updates integration manifest for v2 runtime + web components configuration. |
| components/scprime.js | Removes v1 Ember component JS. |
| LICENSE | Updates license text. |
| .prettierrc | Updates prettier config (removes hbs override; changes printWidth). |
| .prettierignore | Adds ignore rules for build artifacts and lockfile. |
| .npmrc | Adds legacy-peer-deps=true. |
| .gitignore | Ignores dist/. |
| .github/workflows/release-current-version.yml | Removes commented workflow configuration lines. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import { describe, it, expect } from 'vitest'; | ||
|
|
||
| describe('scoutPRIME DetailsComponent', () => { | ||
| it('should be implemented', () => { | ||
| expect(true).toBe(true); | ||
| }); |
There was a problem hiding this comment.
This is a placeholder test (expect(true).toBe(true)) and doesn’t exercise DetailsComponent. Add browser tests that render the component with representative block data (including associations, owners, and whois) and assert that key fields (counts, last activity, cards/toggles) are displayed and toggling works.
| import { describe, it, expect } from 'vitest'; | |
| describe('scoutPRIME DetailsComponent', () => { | |
| it('should be implemented', () => { | |
| expect(true).toBe(true); | |
| }); | |
| import { describe, it, expect, beforeEach, afterEach } from 'vitest'; | |
| const DETAILS_TAG_CANDIDATES = [ | |
| 'details-component', | |
| 'scout-prime-details', | |
| 'scoutprime-details', | |
| ]; | |
| const representativeBlock = { | |
| value: '203.0.113.0/24', | |
| count: 24, | |
| lastActivity: '2024-05-18T12:34:56.000Z', | |
| associations: [ | |
| { | |
| kind: 'domain', | |
| value: 'example.test', | |
| count: 2, | |
| lastActivity: '2024-05-17T09:00:00.000Z', | |
| }, | |
| { | |
| kind: 'ip', | |
| value: '203.0.113.10', | |
| count: 1, | |
| lastActivity: '2024-05-16T08:30:00.000Z', | |
| }, | |
| ], | |
| owners: [ | |
| { | |
| name: 'Example Owner', | |
| email: 'owner@example.test', | |
| lastActivity: '2024-05-15T07:45:00.000Z', | |
| }, | |
| { | |
| name: 'Abuse Desk', | |
| email: 'abuse@example.test', | |
| lastActivity: '2024-05-14T06:15:00.000Z', | |
| }, | |
| ], | |
| whois: { | |
| org: 'Example Networks', | |
| country: 'US', | |
| handle: 'EXAMPLE-NET', | |
| email: 'noc@example.test', | |
| }, | |
| }; | |
| function nextFrame() { | |
| return new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); | |
| } | |
| async function flushRender() { | |
| await Promise.resolve(); | |
| await nextFrame(); | |
| await nextFrame(); | |
| } | |
| function getRegisteredDetailsTag() { | |
| const candidate = DETAILS_TAG_CANDIDATES.find((tagName) => !!customElements.get(tagName)); | |
| expect(candidate, 'expected DetailsComponent custom element to be registered').toBeTruthy(); | |
| return candidate as string; | |
| } | |
| function getSearchRoots(root: ParentNode): ParentNode[] { | |
| const roots: ParentNode[] = [root]; | |
| const stack = [root]; | |
| while (stack.length > 0) { | |
| const current = stack.pop()!; | |
| const elements = Array.from((current as ParentNode).querySelectorAll?.('*') ?? []); | |
| for (const element of elements) { | |
| const shadowRoot = (element as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot; | |
| if (shadowRoot) { | |
| roots.push(shadowRoot); | |
| stack.push(shadowRoot); | |
| } | |
| } | |
| } | |
| return roots; | |
| } | |
| function getAllText(root: ParentNode) { | |
| return getSearchRoots(root) | |
| .map((searchRoot) => searchRoot.textContent ?? '') | |
| .join(' ') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| } | |
| function queryAllDeep(root: ParentNode, selector: string) { | |
| return getSearchRoots(root).flatMap((searchRoot) => | |
| Array.from(searchRoot.querySelectorAll(selector)), | |
| ); | |
| } | |
| function findToggle(root: ParentNode, label: RegExp) { | |
| const candidates = queryAllDeep( | |
| root, | |
| 'button, summary, [role="button"], [aria-controls], [aria-expanded]', | |
| ); | |
| return candidates.find((element) => label.test(element.textContent ?? '')); | |
| } | |
| async function renderDetails(block = representativeBlock) { | |
| const tagName = getRegisteredDetailsTag(); | |
| const element = document.createElement(tagName) as HTMLElement & Record<string, unknown>; | |
| element.block = block; | |
| element.details = block; | |
| element.data = block; | |
| element.value = block; | |
| document.body.appendChild(element); | |
| await customElements.whenDefined(tagName); | |
| await flushRender(); | |
| return element; | |
| } | |
| describe('scoutPRIME DetailsComponent', () => { | |
| beforeEach(() => { | |
| document.body.innerHTML = ''; | |
| }); | |
| afterEach(() => { | |
| document.body.innerHTML = ''; | |
| }); | |
| it('renders representative block details including counts, last activity, owners, and whois', async () => { | |
| const element = await renderDetails(); | |
| const text = getAllText(element); | |
| expect(text).toContain('203.0.113.0/24'); | |
| expect(text).toMatch(/24/); | |
| expect(text).toMatch(/2024|May|18|12:34/); | |
| expect(text).toMatch(/Associations/i); | |
| expect(text).toMatch(/Owners/i); | |
| expect(text).toMatch(/Whois/i); | |
| }); | |
| it('expands associations, owners, and whois toggles/cards and shows nested values', async () => { | |
| const element = await renderDetails(); | |
| const associationsToggle = findToggle(element, /associations/i); | |
| expect(associationsToggle, 'expected an associations toggle/card control').toBeTruthy(); | |
| (associationsToggle as HTMLElement).click(); | |
| await flushRender(); | |
| expect(getAllText(element)).toContain('example.test'); | |
| expect(getAllText(element)).toContain('203.0.113.10'); | |
| const ownersToggle = findToggle(element, /owners/i); | |
| expect(ownersToggle, 'expected an owners toggle/card control').toBeTruthy(); | |
| (ownersToggle as HTMLElement).click(); | |
| await flushRender(); | |
| expect(getAllText(element)).toContain('Example Owner'); | |
| expect(getAllText(element)).toContain('owner@example.test'); | |
| const whoisToggle = findToggle(element, /whois/i); | |
| expect(whoisToggle, 'expected a whois toggle/card control').toBeTruthy(); | |
| (whoisToggle as HTMLElement).click(); | |
| await flushRender(); | |
| expect(getAllText(element)).toContain('Example Networks'); | |
| expect(getAllText(element)).toContain('noc@example.test'); | |
| }); |
There was a problem hiding this comment.
Agree — implemented. Replaced the placeholder test with proper browser tests that render the DetailsComponent with representative block data and assert header stats, TIC gauge, association sources, collections, ownership, and correct last-activity computation.
| "devDependencies": { | ||
| "prettier": "3.0.1" | ||
| "@eslint/js": "^10.0.1", | ||
| "@polarityio/integration-types": "^1.0.3", | ||
| "@polarityio/pi-components": "^1.5.0", | ||
| "@polarityio/vite-plugin-icl": "^1.0.1", | ||
| "@vitest/browser": "^4.1.4", | ||
| "@vitest/browser-playwright": "^4.1.4", | ||
| "eslint": "^10.2.0", | ||
| "eslint-config-prettier": "^10.1.8", | ||
| "lit": "^3.3.2", | ||
| "playwright": "^1.59.1", | ||
| "prettier": "^3.8.3", | ||
| "typescript": "^5.9.3", | ||
| "typescript-eslint": "^8.58.2", | ||
| "vite": "^7.3.2", | ||
| "vitest": "^4.1.4" |
There was a problem hiding this comment.
Some newly added dev dependencies have strict Node engine requirements (e.g., @polarityio/vite-plugin-icl declares node >=24, and vite/vitest require Node 20+). If your CI/release pipeline or local dev environment runs an older Node version, npm install/build will fail. Consider adding an explicit engines.node field (and/or pinning compatible versions) so the required Node version is enforced/documented.
There was a problem hiding this comment.
Disagree — not implemented. This file is generated and managed by the ICL toolchain (icl upgrade). Node version specifications (including engines in package.json) are managed upstream in the ICL tool itself, not in individual integrations. Changes to ICL-managed configuration should be made in the ICL tool.
| private get _lastActivityAt(): string { | ||
| const results = this._associations; | ||
| if (results.length === 0) return ''; | ||
| return results[results.length - 1].lastSeen; |
There was a problem hiding this comment.
_lastActivityAt assumes the last association in the array is the most recent, but associations are requested sorted by right.ticScore (see src/queries.ts), not by lastSeen. This can show an incorrect “Last Activity” timestamp. Consider computing the max lastSeen across results (or changing the API sort to lastSeen).
| return results[results.length - 1].lastSeen; | |
| let latestLastSeen = ''; | |
| let latestTimestamp = Number.NEGATIVE_INFINITY; | |
| for (const result of results) { | |
| if (!result.lastSeen) continue; | |
| const timestamp = Date.parse(result.lastSeen); | |
| if (!Number.isNaN(timestamp) && timestamp > latestTimestamp) { | |
| latestTimestamp = timestamp; | |
| latestLastSeen = result.lastSeen; | |
| } | |
| } | |
| return latestLastSeen; |
There was a problem hiding this comment.
Agree — implemented. Updated _lastActivityAt to compute the maximum lastSeen timestamp across all association results rather than assuming the last array element is the most recent. The suggested approach was adopted since associations are sorted by right.ticScore (descending), not by recency.
| ], | ||
| from: 0, | ||
| limit: 25, | ||
| sortBy: [['right.ticScore', 'desc']] |
There was a problem hiding this comment.
The query sorts associations by right.ticScore descending, but the UI derives “Last Activity” from the last item’s lastSeen. If “Last Activity” is intended to reflect recency, this should sort by lastSeen (or the UI should compute the latest timestamp independently).
| sortBy: [['right.ticScore', 'desc']] | |
| sortBy: [['lastSeen', 'asc']] |
There was a problem hiding this comment.
Disagree — not implemented. Changing the API sort order from right.ticScore desc to lastSeen asc would alter how associations are displayed in the UI — highest risk items should appear first. The correct fix is in the UI layer (computing max lastSeen independently), which has been implemented in the _lastActivityAt getter per the companion comment.
| export interface AssociationResult { | ||
| right: { | ||
| description: string; | ||
| cvss: number; |
There was a problem hiding this comment.
AssociationResult.right.cvss is typed as number, but the provided mock data includes null values. This type should allow null (or the response should be normalized) to avoid incorrect typing and potential runtime assumptions downstream.
| cvss: number; | |
| cvss: number | null; |
There was a problem hiding this comment.
Agree — implemented. Updated cvss type from number to number | null to match actual API responses where CVSS values can be null (confirmed by mock data).
| interface AssociationResult { | ||
| right: { | ||
| description: string; | ||
| cvss: number; |
There was a problem hiding this comment.
AssociationResult.right.cvss is declared as number, but mock data in the repo includes null CVSS values. Update this interface to allow null (or normalize the data) so the web component’s typing matches actual responses.
| cvss: number; | |
| cvss: number | null; |
There was a problem hiding this comment.
Agree — implemented. Updated cvss type from number to number | null in the web component's AssociationResult interface to match the server-side type and actual API responses.
| const associations = await getAssociations(entity, options, request); | ||
| const whois = await getWhois(entity, options, request); | ||
| const owners = await getOwners(entity, options, request); |
There was a problem hiding this comment.
In doLookup, getAssociations, getWhois, and getOwners are awaited sequentially per entity even though they’re independent. This adds avoidable latency and increases lookup timeout risk; consider fetching them concurrently (e.g., Promise.all) and then composing the result.
| const associations = await getAssociations(entity, options, request); | |
| const whois = await getWhois(entity, options, request); | |
| const owners = await getOwners(entity, options, request); | |
| const [associations, whois, owners] = await Promise.all([ | |
| getAssociations(entity, options, request), | |
| getWhois(entity, options, request), | |
| getOwners(entity, options, request) | |
| ]); |
There was a problem hiding this comment.
Agree — implemented. Replaced sequential await calls with Promise.all to run getAssociations, getWhois, and getOwners concurrently. These are independent API requests so parallelizing reduces per-entity latency.
| import { describe, it, expect } from 'vitest'; | ||
|
|
||
| describe('scoutPRIME integration', () => { | ||
| it('should be implemented', () => { | ||
| expect(true).toBe(true); | ||
| }); |
There was a problem hiding this comment.
This test is currently a placeholder (expect(true).toBe(true)) and doesn’t validate any integration behavior. Since the PR introduces new v2 lookup logic, add assertions around doLookup results (e.g., filtering by searchCriteria, handling of empty API responses, and summary tag formatting).
| import { describe, it, expect } from 'vitest'; | |
| describe('scoutPRIME integration', () => { | |
| it('should be implemented', () => { | |
| expect(true).toBe(true); | |
| }); | |
| import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; | |
| import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; | |
| import path from 'node:path'; | |
| import { pathToFileURL } from 'node:url'; | |
| type LookupFn = (...args: any[]) => any; | |
| const projectRoot = path.resolve(__dirname, '..'); | |
| const candidateRoots = ['src', 'lib']; | |
| const candidateExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']); | |
| function findDoLookupModuleFile(): string { | |
| const queue = candidateRoots | |
| .map((dir) => path.join(projectRoot, dir)) | |
| .filter((dir) => existsSync(dir)); | |
| while (queue.length > 0) { | |
| const current = queue.shift()!; | |
| for (const entry of readdirSync(current)) { | |
| const fullPath = path.join(current, entry); | |
| const stats = statSync(fullPath); | |
| if (stats.isDirectory()) { | |
| queue.push(fullPath); | |
| continue; | |
| } | |
| if (!candidateExtensions.has(path.extname(fullPath))) { | |
| continue; | |
| } | |
| const source = readFileSync(fullPath, 'utf8'); | |
| if ( | |
| /\bexport\s+(async\s+)?function\s+doLookup\b/.test(source) || | |
| /\bexport\s+const\s+doLookup\b/.test(source) || | |
| /\bexport\s*\{[^}]*\bdoLookup\b[^}]*\}/.test(source) | |
| ) { | |
| return fullPath; | |
| } | |
| } | |
| } | |
| throw new Error('Unable to locate a module exporting doLookup'); | |
| } | |
| async function loadDoLookup(): Promise<LookupFn> { | |
| const moduleFile = findDoLookupModuleFile(); | |
| const imported = await import(pathToFileURL(moduleFile).href); | |
| if (typeof imported.doLookup !== 'function') { | |
| throw new TypeError(`Expected doLookup export from ${moduleFile}`); | |
| } | |
| return imported.doLookup as LookupFn; | |
| } | |
| function makeApiPayload(items: any[]) { | |
| return { | |
| items, | |
| results: items, | |
| data: items, | |
| value: items, | |
| }; | |
| } | |
| function normalizeResults(result: any): any[] { | |
| if (Array.isArray(result)) { | |
| return result; | |
| } | |
| if (!result || typeof result !== 'object') { | |
| return []; | |
| } | |
| const possibleArrays = [ | |
| result.items, | |
| result.results, | |
| result.data, | |
| result.value, | |
| result.matches, | |
| result.entries, | |
| ]; | |
| for (const value of possibleArrays) { | |
| if (Array.isArray(value)) { | |
| return value; | |
| } | |
| } | |
| return []; | |
| } | |
| async function invokeDoLookup(doLookup: LookupFn, searchCriteria: string) { | |
| const attempts = [ | |
| () => doLookup({ searchCriteria }), | |
| () => doLookup({ query: searchCriteria, searchCriteria }), | |
| () => doLookup(searchCriteria), | |
| ]; | |
| let lastError: unknown; | |
| for (const attempt of attempts) { | |
| try { | |
| return await attempt(); | |
| } catch (error) { | |
| lastError = error; | |
| } | |
| } | |
| throw lastError; | |
| } | |
| describe('scoutPRIME integration', () => { | |
| const originalFetch = globalThis.fetch; | |
| beforeEach(() => { | |
| vi.restoreAllMocks(); | |
| }); | |
| afterEach(() => { | |
| globalThis.fetch = originalFetch; | |
| }); | |
| it('filters lookup results using searchCriteria', async () => { | |
| const doLookup = await loadDoLookup(); | |
| globalThis.fetch = vi.fn(async () => ({ | |
| ok: true, | |
| json: async () => | |
| makeApiPayload([ | |
| { | |
| id: '1', | |
| title: 'Alpha Result', | |
| name: 'Alpha Result', | |
| summary: 'Alpha summary', | |
| tags: ['priority'], | |
| }, | |
| { | |
| id: '2', | |
| title: 'Beta Result', | |
| name: 'Beta Result', | |
| summary: 'Beta summary', | |
| tags: ['other'], | |
| }, | |
| ]), | |
| })) as typeof fetch; | |
| const result = await invokeDoLookup(doLookup, 'Alpha'); | |
| const items = normalizeResults(result); | |
| expect(items.length).toBeGreaterThan(0); | |
| expect( | |
| items.every((item) => | |
| JSON.stringify(item).toLowerCase().includes('alpha'), | |
| ), | |
| ).toBe(true); | |
| expect( | |
| items.some((item) => | |
| JSON.stringify(item).toLowerCase().includes('beta'), | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('returns an empty result set when the API response has no items', async () => { | |
| const doLookup = await loadDoLookup(); | |
| globalThis.fetch = vi.fn(async () => ({ | |
| ok: true, | |
| json: async () => makeApiPayload([]), | |
| })) as typeof fetch; | |
| const result = await invokeDoLookup(doLookup, 'anything'); | |
| const items = normalizeResults(result); | |
| expect(items).toEqual([]); | |
| }); | |
| it('formats summary output with tag content', async () => { | |
| const doLookup = await loadDoLookup(); | |
| globalThis.fetch = vi.fn(async () => ({ | |
| ok: true, | |
| json: async () => | |
| makeApiPayload([ | |
| { | |
| id: '1', | |
| title: 'Alpha Result', | |
| name: 'Alpha Result', | |
| summary: 'Alpha summary', | |
| tags: ['security', 'urgent'], | |
| }, | |
| ]), | |
| })) as typeof fetch; | |
| const result = await invokeDoLookup(doLookup, 'Alpha'); | |
| const items = normalizeResults(result); | |
| const summaryText = JSON.stringify(items[0] ?? result); | |
| expect(items.length).toBeGreaterThan(0); | |
| expect(summaryText).toContain('security'); | |
| expect(summaryText).toContain('urgent'); | |
| expect(summaryText).not.toContain('[object Object]'); | |
| }); |
There was a problem hiding this comment.
Agree — implemented. Replaced the placeholder with proper unit tests that mock polarity-integration-utils and the query functions, then exercise doLookup (search criteria filtering, summary tag formatting, URL normalization), validateOptions (missing/empty fields), and startup (logger initialization).
- Fix cvss type to number | null in scout-prime.ts and details.ts - Fix _lastActivityAt to compute max lastSeen across all associations - Parallelize getAssociations/getWhois/getOwners with Promise.all - Replace placeholder tests with proper server (21) and browser (12) tests - Add resolve alias in vitest.config.ts for polarity-integration-utils Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Move PolarityRequest instantiation to startup() following the v2 pattern for a module-level, reusable instance. Set request.network = context.network in doLookup so proxy and TLS settings from the platform context are applied to all outbound requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
v1 → v2 Migration
Repository:
scoutprimeLinear Ticket: INT-1778
This PR was generated by the Polarity Integration Upgrade CLI.