Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions tests/unit/web/pm-wizard-styling-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Regression guard: PM wizard component files must not render raw HTML
* interactive elements (<input>, <button>, <select>, <label>) directly.
* They must route through the shadcn/ui primitives (`Input`, `Button`,
* `NativeSelect`, `Label`) so the dashboard theme + Tailwind reset apply.
*
* The original regression (specs 010/3 + 011 + 012) shipped raw elements
* with BEM-style class names (`pm-wizard-*`) that were defined nowhere in
* the CSS bundle — on the dark theme the Linear API-key input rendered as
* an invisible <input type="password"> because there was no border, no
* padding, and browser-default transparent background. Root `tsc` and the
* SSR tests passed because neither asserted visual output.
*
* This test greps every `.tsx` source under pm-providers/** for
* `createElement('input' | 'button' | 'select' | 'label', ...)` and JSX
* `<input | <button | <select | <label` patterns, asserting zero matches.
*/

import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = resolve(__dirname, '..', '..', '..');
const WIZARD_ROOT = resolve(REPO_ROOT, 'web/src/components/projects/pm-providers');

// Files allowed to use a raw element, with a reason. Empty by design — add
// entries only with justification.
const ALLOWLIST = new Set<string>([]);

const RAW_HTML_ELEMENTS = ['input', 'button', 'select', 'label'] as const;

function walkTsx(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
if (entry === 'node_modules' || entry === 'dist') continue;
walkTsx(full, out);
} else if (entry.endsWith('.tsx')) {
out.push(full);
}
}
return out;
}

/**
* Strip block comments (`/* ... *\/`) and line comments (`// ...`) so that
* mentions of raw elements in JSDoc prose don't trigger false positives.
*/
function stripComments(source: string): string {
// Block comments — non-greedy, multi-line.
let out = source.replace(/\/\*[\s\S]*?\*\//g, '');
// Line comments — everything from `//` to EOL. Naive but sufficient here:
// the wizard files never embed `//` inside string literals in a way that
// would confuse this (URLs use template literals / string concat).
out = out.replace(/\/\/[^\n]*/g, '');
return out;
}

function findViolations(source: string): string[] {
const stripped = stripComments(source);
const violations: string[] = [];
for (const tag of RAW_HTML_ELEMENTS) {
const createElementRe = new RegExp(`createElement\\(\\s*['"]${tag}['"]`, 'g');
for (const m of stripped.matchAll(createElementRe)) {
violations.push(`createElement('${tag}'): index ${m.index}`);
}
const jsxRe = new RegExp(`<${tag}(?=[\\s/>])`, 'g');
for (const m of stripped.matchAll(jsxRe)) {
violations.push(`<${tag}: index ${m.index}`);
}
}
return violations;
}

describe('pm-wizard styling guard', () => {
const files = walkTsx(WIZARD_ROOT)
.map((abs) => relative(REPO_ROOT, abs))
.sort();

it('finds wizard component files to audit', () => {
expect(files.length).toBeGreaterThan(0);
});

it.each(files)('%s uses shadcn primitives, not raw HTML interactive elements', (relPath) => {
if (ALLOWLIST.has(relPath)) return;
const source = readFileSync(resolve(REPO_ROOT, relPath), 'utf8');
const violations = findViolations(source);
expect(
violations,
`${relPath} must route interactive elements through shadcn primitives ` +
`(Input, Button, NativeSelect, Label). Found raw HTML: ${violations.join(', ')}`,
).toEqual([]);
});
});
8 changes: 6 additions & 2 deletions tests/unit/web/steps/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ describe('CredentialsStep', () => {
onChange: () => {},
}),
);
// api_token → password; api_key → text (role doesn't include 'token'/'password')
expect(html).toMatch(/id="cred-api_token"[^>]*type="password"/);
// api_token → password; api_key → text (role doesn't include 'token'/'password').
// Order-agnostic: the <input> element can emit attributes in any order depending
// on the wrapper (shadcn Input sets type before spread-in id).
expect(html).toMatch(
/<input[^>]*id="cred-api_token"[^>]*type="password"|<input[^>]*type="password"[^>]*id="cred-api_token"/,
);
});

it('renders verify button when onVerify is supplied', () => {
Expand Down
14 changes: 8 additions & 6 deletions tests/unit/web/steps/custom-field-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,15 @@ describe('CustomFieldMappingStep', () => {
});
const elements: ReactElement[] = [];
flatten(tree, elements);
const selects = elements.filter((el) => el.type === 'select');
expect(selects.length).toBe(2);
const costSelect = selects.find(
(el) => (el.props as { id?: string }).id === 'custom-field-cost',
// Post-styling-restoration: the select is wrapped in `NativeSelect`
// (shadcn primitive) rather than raw `<select>`. Walk by prop id
// instead of element type — the primitive forwards the `id` prop and
// `onChange` handler unchanged, so wiring assertions are preserved.
const costSelectEl = elements.find(
(el) => (el.props as { id?: string } | undefined)?.id === 'custom-field-cost',
);
expect(costSelect).toBeDefined();
const onChange = (costSelect?.props as { onChange?: (e: unknown) => void }).onChange;
expect(costSelectEl).toBeDefined();
const onChange = (costSelectEl?.props as { onChange?: (e: unknown) => void }).onChange;
expect(onChange).toBeTypeOf('function');
onChange?.({ target: { value: 'fld-2' } });
expect(onMappingChange).toHaveBeenCalledWith('cost', 'fld-2');
Expand Down
4 changes: 3 additions & 1 deletion tests/unit/web/steps/webhook-url-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ describe('WebhookUrlDisplayStep', () => {
webhookUrl: 'https://router.example.com/trello/webhook',
}),
);
expect(html).toContain('<code>https://router.example.com/trello/webhook</code>');
// The <code> element may carry Tailwind classes; match loosely on the
// tag + URL + closing tag rather than the exact attribute-free opener.
expect(html).toMatch(/<code[^>]*>https:\/\/router\.example\.com\/trello\/webhook<\/code>/);
expect(html).toContain('data-url="https://router.example.com/trello/webhook"');
});

Expand Down
2 changes: 1 addition & 1 deletion web/src/components/projects/integration-alerting-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Trash2 } from 'lucide-react';
import { useState } from 'react';
import { CopyButton } from '@/components/ui/copy-button.js';
import { Input } from '@/components/ui/input.js';
import { Label } from '@/components/ui/label.js';
import { API_URL } from '@/lib/api.js';
import { trpc, trpcClient } from '@/lib/trpc.js';
import { CopyButton } from './integration-scm-tab.js';
import { ProjectSecretField } from './project-secret-field.js';

// ============================================================================
Expand Down
32 changes: 4 additions & 28 deletions web/src/components/projects/integration-scm-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,27 @@
/**
* SCM (GitHub) integration tab components.
* Contains: CopyButton, GitHubCredentialSlots, GitHubWebhookSection, SCMTab.
* CopyButton is co-located here and also exported for use by AlertingTab.
* Contains: GitHubCredentialSlots, GitHubWebhookSection, SCMTab.
* `CopyButton` lives at `@/components/ui/copy-button.js` (extracted during
* PM wizard styling restoration).
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertCircle,
AlertTriangle,
Check,
Clipboard,
ExternalLink,
Info,
Loader2,
RefreshCw,
Trash2,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { CopyButton } from '@/components/ui/copy-button.js';
import { Input } from '@/components/ui/input.js';
import { Label } from '@/components/ui/label.js';
import { API_URL } from '@/lib/api.js';
import { trpc, trpcClient } from '@/lib/trpc.js';
import { ProjectSecretField } from './project-secret-field.js';

// ============================================================================
// CopyButton (shared with AlertingTab)
// ============================================================================

export function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1 shrink-0 rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title="Copy to clipboard"
>
{copied ? <Check className="h-3 w-3 text-green-600" /> : <Clipboard className="h-3 w-3" />}
{copied ? 'Copied' : 'Copy'}
</button>
);
}

// ============================================================================
// GitHub Credential Slots (replaces the old CredentialSelector dropdowns)
// ============================================================================
Expand Down
32 changes: 25 additions & 7 deletions web/src/components/projects/pm-providers/jira/issue-type-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
*/

import { createElement } from 'react';
import { Label } from '@/components/ui/label.js';
import { NativeSelect } from '@/components/ui/native-select.js';
import type { CustomStep } from '../../../../../../src/integrations/pm/manifest.js';

export interface JiraIssueType {
Expand Down Expand Up @@ -53,15 +55,23 @@ export function IssueTypeMappingStep({
'data-step-component': 'issue-type-mapping',
'data-provider-id': providerId,
'data-step-id': step.id,
className: 'pm-wizard-step pm-wizard-step-issue-type-mapping',
className: 'space-y-3',
},
loading
? createElement('p', { 'data-state': 'loading' }, 'Loading issue types…')
? createElement(
'p',
{ 'data-state': 'loading', className: 'text-sm text-muted-foreground' },
'Loading issue types…',
)
: error
? createElement('p', { 'data-state': 'error' }, `Error: ${error}`)
? createElement(
'p',
{ 'data-state': 'error', className: 'text-sm text-destructive' },
`Error: ${error}`,
)
: createElement(
'div',
{ className: 'pm-wizard-issue-type-mappings' },
{ className: 'space-y-2' },
...ROLES.map((role) => {
const fieldId = `issue-type-${role.key}`;
const filtered = issueTypes.filter((t) => t.subtask === role.subtaskFlag);
Expand All @@ -70,17 +80,25 @@ export function IssueTypeMappingStep({
'div',
{
key: role.key,
className: 'pm-wizard-issue-type-row',
className: 'flex items-center gap-3',
'data-role': role.key,
},
createElement('label', { htmlFor: fieldId }, role.label),
createElement(
'select',
Label,
{
htmlFor: fieldId,
className: 'w-32 shrink-0 text-xs text-muted-foreground',
},
role.label,
),
createElement(
NativeSelect,
{
id: fieldId,
value: currentValue,
onChange: (e: React.ChangeEvent<HTMLSelectElement>) =>
onMappingChange(role.key, e.target.value),
className: 'flex-1',
},
createElement('option', { value: '' }, '— Select —'),
...filtered.map((t) =>
Expand Down
Loading
Loading