Skip to content

v2 Migration: scoutprime - #31

Merged
sarus merged 4 commits into
developfrom
v2/migration
Jun 1, 2026
Merged

v2 Migration: scoutprime#31
sarus merged 4 commits into
developfrom
v2/migration

Conversation

@sarus

@sarus sarus commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

v1 → v2 Migration

Repository: scoutprime
Linear Ticket: INT-1778

This PR was generated by the Polarity Integration Upgrade CLI.

- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to runtimeVersion: 2.
  • Rewrites the integration backend in TypeScript (src/integration.ts, src/queries.ts) using polarity-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.

Comment thread test/web-components/details.test.ts Outdated
Comment on lines +1 to +6
import { describe, it, expect } from 'vitest';

describe('scoutPRIME DetailsComponent', () => {
it('should be implemented', () => {
expect(true).toBe(true);
});

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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');
});

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread package.json
Comment on lines 9 to +24
"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"

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread web-components/details.ts Outdated
private get _lastActivityAt(): string {
const results = this._associations;
if (results.length === 0) return '';
return results[results.length - 1].lastSeen;

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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).

Suggested change
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;

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/queries.ts
],
from: 0,
limit: 25,
sortBy: [['right.ticScore', 'desc']]

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
sortBy: [['right.ticScore', 'desc']]
sortBy: [['lastSeen', 'asc']]

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/types/scout-prime.ts Outdated
export interface AssociationResult {
right: {
description: string;
cvss: number;

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
cvss: number;
cvss: number | null;

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread web-components/details.ts Outdated
interface AssociationResult {
right: {
description: string;
cvss: number;

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
cvss: number;
cvss: number | null;

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/integration.ts Outdated
Comment on lines +38 to +40
const associations = await getAssociations(entity, options, request);
const whois = await getWhois(entity, options, request);
const owners = await getOwners(entity, options, request);

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
]);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread test/integration.test.ts Outdated
Comment on lines +1 to +6
import { describe, it, expect } from 'vitest';

describe('scoutPRIME integration', () => {
it('should be implemented', () => {
expect(true).toBe(true);
});

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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]');
});

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

sarus and others added 3 commits April 28, 2026 16:29
- 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>
@sarus
sarus merged commit 0bd4e8d into develop Jun 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants