Skip to content
1 change: 1 addition & 0 deletions __generated__/dockview-core-exports.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
PROPERTY_KEYS_DOCKVIEW,
DockviewFrameworkOptions,
DockviewComponentOptions,
CspNonce,
GetTabContextMenuItemsParams,
GetTabGroupChipContextMenuItemsParams,
BuiltInChipContextMenuItem,
Expand Down Expand Up @@ -94,6 +95,7 @@ export class DockviewAngularComponent implements OnInit, OnDestroy, OnChanges {
@Input() disableFloatingGroups?: boolean;
@Input() floatingGroupBounds?: 'boundedWithinViewport';
@Input() popoutUrl?: string;
@Input() nonce?: CspNonce;
@Input() debug?: boolean;
@Input() locked?: boolean;
@Input() disableAutoResizing?: boolean;
Expand Down
7 changes: 7 additions & 0 deletions packages/dockview-core/src/__tests__/dockview/options.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PROPERTY_KEYS_DOCKVIEW } from '../../dockview/options';

describe('PROPERTY_KEYS_DOCKVIEW', () => {
test('includes nonce so framework wrappers (React, Vue) auto-forward it', () => {
expect(PROPERTY_KEYS_DOCKVIEW).toContain('nonce');
});
});
143 changes: 143 additions & 0 deletions packages/dockview-core/src/__tests__/dom.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
addStyles,
disableIframePointEvents,
isInDocument,
quasiDefaultPrevented,
Expand Down Expand Up @@ -80,4 +81,146 @@ describe('dom', () => {
expect(el3.style.pointerEvents).toBe('inherit');
expect(el4.style.pointerEvents).toBe('');
});

describe('addStyles', () => {
function makeTargetDocument() {
return document.implementation.createHTMLDocument('target');
}

function makeStyleSheet(rules: string[], href?: string): CSSStyleSheet {
return {
href,
type: 'text/css',
cssRules: rules.map((cssText) => ({ cssText })),
} as unknown as CSSStyleSheet;
}

function makeStyleSheetList(sheets: CSSStyleSheet[]): StyleSheetList {
const list: any = {
length: sheets.length,
[Symbol.iterator]: function* () {
for (const s of sheets) yield s;
},
};
sheets.forEach((s, i) => (list[i] = s));
return list as StyleSheetList;
}

test('applies nonce to every created <style> element', () => {
const targetDoc = makeTargetDocument();
const sheets = makeStyleSheetList([
makeStyleSheet(['.a { color: red; }', '.b { color: blue; }']),
]);

addStyles(targetDoc, sheets, { nonce: 'abc123' });

const styles = targetDoc.head.querySelectorAll('style');
expect(styles.length).toBe(2);
expect(styles[0].getAttribute('nonce')).toBe('abc123');
expect(styles[1].getAttribute('nonce')).toBe('abc123');
expect(styles[0].textContent).toBe('.a { color: red; }');
expect(styles[1].textContent).toBe('.b { color: blue; }');
});

test('omits nonce attribute when no nonce is supplied', () => {
const targetDoc = makeTargetDocument();
const sheets = makeStyleSheetList([
makeStyleSheet(['.b { color: blue; }']),
]);

addStyles(targetDoc, sheets);

const style = targetDoc.head.querySelector('style')!;
expect(style.hasAttribute('nonce')).toBe(false);
});

test('appends <link> for external stylesheet hrefs and does not duplicate rules inline', () => {
const targetDoc = makeTargetDocument();
const sheets = makeStyleSheetList([
makeStyleSheet(
['.c { color: green; }'],
'https://example.test/main.css'
),
]);

addStyles(targetDoc, sheets, { nonce: 'xyz' });

const link = targetDoc.head.querySelector('link');
expect(link).not.toBeNull();
expect(link?.getAttribute('rel')).toBe('stylesheet');
expect(link?.getAttribute('href')).toBe(
'https://example.test/main.css'
);
// The <link> already loads the sheet in the target document;
// we must not also inject inline <style> for the same rules.
expect(targetDoc.head.querySelectorAll('style').length).toBe(0);
});

test('preserves source order across readable and unreadable sheets', () => {
// Simulate a CORS-restricted sheet whose cssRules throws on access.
const unreadable: any = {
href: 'https://cdn.test/blocked.css',
type: 'text/css',
get cssRules(): CSSRuleList {
throw new DOMException('SecurityError', 'SecurityError');
},
};

const targetDoc = makeTargetDocument();
const sheets = makeStyleSheetList([
makeStyleSheet(['.first { color: red; }']),
unreadable,
makeStyleSheet(['.third { color: green; }']),
]);

const warn = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});

addStyles(targetDoc, sheets, { nonce: 'n1' });

const appended = Array.from(targetDoc.head.children).filter(
(el) => el.tagName === 'STYLE' || el.tagName === 'LINK'
);
expect(appended.length).toBe(3);
expect(appended[0].tagName).toBe('STYLE');
expect(appended[0].textContent).toBe('.first { color: red; }');
expect(appended[1].tagName).toBe('LINK');
expect((appended[1] as HTMLLinkElement).getAttribute('href')).toBe(
'https://cdn.test/blocked.css'
);
expect(appended[2].tagName).toBe('STYLE');
expect(appended[2].textContent).toBe('.third { color: green; }');

warn.mockRestore();
});

test('nonce accepts a function that receives the target document', () => {
const targetDoc = makeTargetDocument();
const meta = targetDoc.createElement('meta');
meta.setAttribute('name', 'csp-nonce');
meta.setAttribute('content', 'from-popout');
targetDoc.head.appendChild(meta);

const sheets = makeStyleSheetList([
makeStyleSheet(['.x { color: red; }']),
]);

const nonceFn = jest.fn(
(doc: Document) =>
doc
.querySelector<HTMLMetaElement>(
'meta[name="csp-nonce"]'
)
?.getAttribute('content') ?? undefined
);

addStyles(targetDoc, sheets, { nonce: nonceFn });

expect(nonceFn).toHaveBeenCalledTimes(1);
expect(nonceFn).toHaveBeenCalledWith(targetDoc);
const style = targetDoc.head.querySelector('style')!;
expect(style.getAttribute('nonce')).toBe('from-popout');
});
});
});
157 changes: 157 additions & 0 deletions packages/dockview-core/src/__tests__/popoutWindow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { PopoutWindow } from '../popoutWindow';

describe('PopoutWindow', () => {
function makeFakeExternalWindow() {
const externalDoc =
document.implementation.createHTMLDocument('popout');
const listeners: Record<string, EventListener[]> = {};

const externalWindow: any = {
document: externalDoc,
close: jest.fn(),
addEventListener: (type: string, fn: EventListener) => {
(listeners[type] ||= []).push(fn);
},
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
};

const fireLoad = () => {
for (const fn of listeners['load'] ?? []) {
fn(new Event('load'));
}
};

return { externalWindow, externalDoc, fireLoad };
}

function withParentStyleSheet<T>(cssText: string, fn: () => T): T {
const styleEl = document.createElement('style');
styleEl.appendChild(document.createTextNode(cssText));
document.head.appendChild(styleEl);
try {
return fn();
} finally {
styleEl.remove();
}
}

test('forwards nonce from options through addStyles into the popout document', async () => {
const { externalWindow, externalDoc, fireLoad } =
makeFakeExternalWindow();
const openSpy = jest
.spyOn(window, 'open')
.mockReturnValue(externalWindow as Window);

try {
await withParentStyleSheet('.dv { color: red; }', async () => {
const popout = new PopoutWindow('target-id', 'dv-test-class', {
url: 'about:blank',
top: 0,
left: 0,
width: 100,
height: 100,
nonce: 'popout-nonce-123',
});

const opened = popout.open();
fireLoad();
await opened;

const styles = externalDoc.head.querySelectorAll('style');
expect(styles.length).toBeGreaterThan(0);
styles.forEach((s) => {
expect(s.getAttribute('nonce')).toBe('popout-nonce-123');
});

popout.dispose();
});
} finally {
openSpy.mockRestore();
}
});

test('resolves nonce function against the popout document', async () => {
const { externalWindow, externalDoc, fireLoad } =
makeFakeExternalWindow();
const meta = externalDoc.createElement('meta');
meta.setAttribute('name', 'csp-nonce');
meta.setAttribute('content', 'from-popout-meta');
externalDoc.head.appendChild(meta);

const openSpy = jest
.spyOn(window, 'open')
.mockReturnValue(externalWindow as Window);

try {
await withParentStyleSheet('.dv { color: red; }', async () => {
const nonceFn = jest.fn(
(doc: Document) =>
doc
.querySelector<HTMLMetaElement>(
'meta[name="csp-nonce"]'
)
?.getAttribute('content') ?? undefined
);

const popout = new PopoutWindow('target-id', 'dv-test-class', {
url: 'about:blank',
top: 0,
left: 0,
width: 100,
height: 100,
nonce: nonceFn,
});

const opened = popout.open();
fireLoad();
await opened;

expect(nonceFn).toHaveBeenCalledWith(externalDoc);
const styles = externalDoc.head.querySelectorAll('style');
expect(styles.length).toBeGreaterThan(0);
styles.forEach((s) => {
expect(s.getAttribute('nonce')).toBe('from-popout-meta');
});

popout.dispose();
});
} finally {
openSpy.mockRestore();
}
});

test('does not set a nonce attribute when no nonce option is supplied', async () => {
const { externalWindow, externalDoc, fireLoad } =
makeFakeExternalWindow();
const openSpy = jest
.spyOn(window, 'open')
.mockReturnValue(externalWindow as Window);

try {
await withParentStyleSheet('.dv { color: red; }', async () => {
const popout = new PopoutWindow('target-id', 'dv-test-class', {
url: 'about:blank',
top: 0,
left: 0,
width: 100,
height: 100,
});

const opened = popout.open();
fireLoad();
await opened;

const styles = externalDoc.head.querySelectorAll('style');
expect(styles.length).toBeGreaterThan(0);
styles.forEach((s) => {
expect(s.hasAttribute('nonce')).toBe(false);
});

popout.dispose();
});
} finally {
openSpy.mockRestore();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ export class WrapTabGroupIndicator extends BaseTabGroupIndicator {
let svg = underline.firstElementChild as SVGSVGElement | null;
let path: SVGPathElement;
if (!svg || svg.tagName !== 'svg') {
underline.innerHTML = '';
underline.replaceChildren();
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.display = 'block';
path = document.createElementNS(
Expand Down Expand Up @@ -494,7 +494,7 @@ export class NoneTabGroupIndicator extends BaseTabGroupIndicator {

// Clear any SVG content left over from a mode switch
if (underline.firstElementChild) {
underline.innerHTML = '';
underline.replaceChildren();
}

underline.style.backgroundColor = color;
Expand Down
1 change: 1 addition & 0 deletions packages/dockview-core/src/dockview/dockviewComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,7 @@ export class DockviewComponent
height: box.height,
onDidOpen: options?.onDidOpen,
onWillClose: options?.onWillClose,
nonce: this.options?.nonce,
}
);

Expand Down
4 changes: 4 additions & 0 deletions packages/dockview-core/src/dockview/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { Contraints } from '../gridview/gridviewPanel';
import { AcceptableEvent, IAcceptableEvent } from '../events';
import { DockviewTheme } from './theme';
import { ITabGroup } from './tabGroup';
import { CspNonce } from '../dom';
export { CspNonce } from '../dom';
import { DockviewTabGroupColorEntry } from './tabGroupAccent';

export interface IHeaderActionsRenderer extends IDisposable {
Expand Down Expand Up @@ -108,6 +110,7 @@ export interface DockviewOptions {
minimumWidthWithinViewport?: number;
};
popoutUrl?: string;
nonce?: CspNonce;
defaultRenderer?: DockviewPanelRenderer;
defaultHeaderPosition?: DockviewHeaderPosition;
debug?: boolean;
Expand Down Expand Up @@ -228,6 +231,7 @@ export const PROPERTY_KEYS_DOCKVIEW: (keyof DockviewOptions)[] = (() => {
disableFloatingGroups: undefined,
floatingGroupBounds: undefined,
popoutUrl: undefined,
nonce: undefined,
defaultRenderer: undefined,
defaultHeaderPosition: undefined,
debug: undefined,
Expand Down
Loading
Loading