Skip to content

Commit

Permalink
feat: [capricorn86#1398] Add support for iframe srcdoc
Browse files Browse the repository at this point in the history
  • Loading branch information
jeffwcx committed Apr 10, 2024
1 parent c29f36c commit 8d125f6
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 37 deletions.
4 changes: 4 additions & 0 deletions packages/happy-dom/src/browser/types/IGoToOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,8 @@ export default interface IGoToOptions extends IReloadOptions {
* Referrer policy.
*/
referrerPolicy?: IRequestReferrerPolicy;
/**
* If `srcdoc` is set, it can be used to replace the data obtained by fetch.
*/
substituteData?: string;
}
69 changes: 40 additions & 29 deletions packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,21 @@ export default class BrowserFrameNavigator {
}

(<IBrowserFrame[]>frame.childFrames) = [];
const parentWindow = frame.window.parent;
frame.window[PropertySymbol.destroy]();
frame[PropertySymbol.asyncTaskManager].destroy();
frame[PropertySymbol.asyncTaskManager] = new AsyncTaskManager();

(<BrowserWindow>frame.window) = new windowClass(frame, { url: targetURL.href, width, height });
(<BrowserWindow>(<unknown>frame.window.top)) = parentWindow;
(<BrowserWindow>(<unknown>frame.window.parent)) = parentWindow;
(<number>frame.window.devicePixelRatio) = devicePixelRatio;

if (referrer) {
frame.window.document[PropertySymbol.referrer] = referrer;
}

if (targetURL.protocol === 'about:') {
if (targetURL.protocol === 'about:' && goToOptions?.substituteData === void 0) {
return null;
}

Expand Down Expand Up @@ -138,37 +141,45 @@ export default class BrowserFrameNavigator {
}
};

try {
response = await frame.window.fetch(targetURL.href, {
referrer,
referrerPolicy: goToOptions?.referrerPolicy || 'origin',
signal: abortController.signal,
method: method || (formData ? 'POST' : 'GET'),
headers: goToOptions?.hard ? { 'Cache-Control': 'no-cache' } : undefined,
body: formData
if (goToOptions?.substituteData !== void 0) {
responseText = goToOptions.substituteData;
response = new frame.window.Response(goToOptions.substituteData, {
headers: { 'Content-Type': 'text/html' }
});

// Handles the "X-Frame-Options" header for child frames.
if (frame.parentFrame) {
const originURL = frame.parentFrame.window.location;
const xFrameOptions = response.headers?.get('X-Frame-Options')?.toLowerCase();
const isSameOrigin = originURL.origin === targetURL.origin || targetURL.origin === 'null';

if (xFrameOptions === 'deny' || (xFrameOptions === 'sameorigin' && !isSameOrigin)) {
throw new Error(
`Refused to display '${url}' in a frame because it set 'X-Frame-Options' to '${xFrameOptions}'.`
);
} else {
try {
response = await frame.window.fetch(targetURL.href, {
referrer,
referrerPolicy: goToOptions?.referrerPolicy || 'origin',
signal: abortController.signal,
method: method || (formData ? 'POST' : 'GET'),
headers: goToOptions?.hard ? { 'Cache-Control': 'no-cache' } : undefined,
body: formData
});

// Handles the "X-Frame-Options" header for child frames.
if (frame.parentFrame) {
const originURL = frame.parentFrame.window.location;
const xFrameOptions = response.headers?.get('X-Frame-Options')?.toLowerCase();
const isSameOrigin = originURL.origin === targetURL.origin || targetURL.origin === 'null';

if (xFrameOptions === 'deny' || (xFrameOptions === 'sameorigin' && !isSameOrigin)) {
throw new Error(
`Refused to display '${url}' in a frame because it set 'X-Frame-Options' to '${xFrameOptions}'.`
);
}
}
}

responseText = await response.text();
} catch (error) {
finalize();
throw error;
}

if (!response.ok) {
frame.page.console.error(`GET ${targetURL.href} ${response.status} (${response.statusText})`);
responseText = await response.text();
} catch (error) {
finalize();
throw error;
}
if (!response.ok) {
frame.page.console.error(
`GET ${targetURL.href} ${response.status} (${response.statusText})`
);
}
}

// Fixes issue where evaluating the response can throw an error.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ export default class HTMLIFrameElementNamedNodeMap extends HTMLElementNamedNodeM
*/
public override setNamedItem(item: Attr): Attr | null {
const replacedAttribute = super.setNamedItem(item);

if (item[PropertySymbol.name] === 'srcdoc') {
this.#pageLoader.loadPage();
}
// If the src attribute and the srcdoc attribute are both specified together, the srcdoc attribute takes priority.
if (
item[PropertySymbol.name] === 'src' &&
this[PropertySymbol.ownerElement].getAttribute('srcdoc') === null &&
item[PropertySymbol.value] &&
item[PropertySymbol.value] !== replacedAttribute?.[PropertySymbol.value]
) {
Expand All @@ -70,6 +74,21 @@ export default class HTMLIFrameElementNamedNodeMap extends HTMLElementNamedNodeM
return replacedAttribute || null;
}

/**
* @override
*/
public override [PropertySymbol.removeNamedItem](name: string): Attr | null {
const removedItem = super[PropertySymbol.removeNamedItem](name);
if (
removedItem &&
(removedItem[PropertySymbol.name] === 'srcdoc' || removedItem[PropertySymbol.name] === 'src')
) {
this.#pageLoader.loadPage();
}

return removedItem;
}

/**
*
* @param tokens
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js';
import BrowserFrameURL from '../../browser/utilities/BrowserFrameURL.js';
import BrowserFrameFactory from '../../browser/utilities/BrowserFrameFactory.js';
import IRequestReferrerPolicy from '../../fetch/types/IRequestReferrerPolicy.js';
import IGoToOptions from '../../browser/types/IGoToOptions.js';

/**
* HTML Iframe page loader.
Expand Down Expand Up @@ -51,10 +52,13 @@ export default class HTMLIFrameElementPageLoader {
this.#contentWindowContainer.window = null;
return;
}

const srcdoc = this.#element.getAttribute('srcdoc');
const window = this.#element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow];
const originURL = this.#browserParentFrame.window.location;
const targetURL = BrowserFrameURL.getRelativeURL(this.#browserParentFrame, this.#element.src);
const targetURL = BrowserFrameURL.getRelativeURL(
this.#browserParentFrame,
srcdoc !== null ? 'about:srcdoc' : this.#element.src
);

if (this.#browserIFrame && this.#browserIFrame.window.location.href === targetURL.href) {
return;
Expand Down Expand Up @@ -83,11 +87,17 @@ export default class HTMLIFrameElementPageLoader {
(<BrowserWindow | CrossOriginBrowserWindow>(<unknown>this.#browserIFrame.window.parent)) =
parentWindow;

const gotoOptions: IGoToOptions = {
referrer: originURL.origin,
referrerPolicy: <IRequestReferrerPolicy>this.#element.referrerPolicy
};

if (srcdoc !== null) {
gotoOptions.substituteData = srcdoc;
}

this.#browserIFrame
.goto(targetURL.href, {
referrer: originURL.origin,
referrerPolicy: <IRequestReferrerPolicy>this.#element.referrerPolicy
})
.goto(targetURL.href, gotoOptions)
.then(() => this.#element.dispatchEvent(new Event('load')))
.catch((error) => WindowErrorUtility.dispatchError(this.#element, error));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('HTMLIFrameElement', () => {
});
});

for (const property of ['src', 'allow', 'height', 'width', 'name', 'srcdoc']) {
for (const property of ['src', 'allow', 'height', 'width', 'name']) {
describe(`get ${property}()`, () => {
it(`Returns the "${property}" attribute.`, () => {
element.setAttribute(property, 'value');
Expand Down Expand Up @@ -126,6 +126,105 @@ describe('HTMLIFrameElement', () => {
});
});

describe('get srcdoc()', () => {
it('Returns string', () => {
expect(element.srcdoc).toBe('');
element.srcdoc = '<div></div>';
expect(element.getAttribute('srcdoc')).toBe('<div></div>');
});
});

describe('set srcdoc()', () => {
it("Navigate the element's browsing context to a resource whose Content-Type is text/html", async () => {
const actualHTML = await new Promise((resolve) => {
element.srcdoc = '<div>TEST</div>';
element.addEventListener('load', () => {
resolve(element.contentDocument?.documentElement.innerHTML);
});
document.body.appendChild(element);
});
expect(actualHTML).toBe('<head></head><body><div>TEST</div></body>');
});

it('Takes priority, when the src attribute and the srcdoc attribute are both specified together', async () => {
const browser = new Browser();
const page = browser.newPage();
const window = page.mainFrame.window;
const document = window.document;
const element = <HTMLIFrameElement>document.createElement('iframe');
const url = await new Promise((resolve) => {
element.srcdoc = '<html><head></head><body>TEST</body></html>';
element.src = 'https://localhost:8080/iframe.html';
element.addEventListener('load', () => {
resolve(page.mainFrame.childFrames[0].url);
});
document.appendChild(element);
});
expect(url).toBe('about:srcdoc');
});

it('Resolve the value of the src attribute when the srcdoc attribute has been removed', async () => {
const browser = new Browser();
const page = browser.newPage();
const window = page.mainFrame.window;
const document = window.document;
const element = <HTMLIFrameElement>document.createElement('iframe');
page.mainFrame.url = 'https://localhost:8080';
const responseHTML = '<html><head></head><body>Test</body></html>';

vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => {
return Promise.resolve(<Response>(<unknown>{
text: () => Promise.resolve(responseHTML),
ok: true,
headers: new Headers()
}));
});
const frameUrl = 'https://localhost:8080/iframe.html';
const actualFrameUrl = await new Promise((resolve) => {
element.srcdoc = responseHTML;
element.src = frameUrl;
const firstLoad = (): void => {
expect(page.mainFrame.childFrames[0].url).toBe('about:srcdoc');
element.removeEventListener('load', firstLoad);
element.addEventListener('load', () => {
resolve(page.mainFrame.childFrames[0].url);
});
element.removeAttribute('srcdoc');
};
element.addEventListener('load', firstLoad);
document.body.appendChild(element);
});
expect(actualFrameUrl).toBe(frameUrl);
});

it('Execute code in the script', async () => {
const message = await new Promise((resolve) => {
element.srcdoc = `<!doctype html><html><head>
<script>
function handleMessage(e) {
parent.postMessage({ msg: 'loaded' }, '*');
}
window.addEventListener('message', handleMessage, false);
</script>
</head><body></body></html>`;
element.addEventListener('load', () => {
element.contentWindow?.postMessage('MESSAGE', '*');
});
window.addEventListener(
'message',
(e) => {
const data = (<MessageEvent>e).data;
resolve(data);
},
false
);
document.body.appendChild(element);
expect(element.contentWindow?.parent === window).toBe(true);
});
expect(message).toMatchObject({ msg: 'loaded' });
});
});

describe('get contentWindow()', () => {
it('Returns content window for "about:blank".', () => {
element.src = 'about:blank';
Expand Down Expand Up @@ -421,13 +520,44 @@ describe('HTMLIFrameElement', () => {
document.body.appendChild(element);
});
});

it('Remain at the initial about:blank page when none of the srcdoc/src attributes are set', async () => {
const browser = new Browser();
const page = browser.newPage();
const window = page.mainFrame.window;
const document = window.document;
const element = <HTMLIFrameElement>document.createElement('iframe');
page.mainFrame.url = 'https://localhost:8080';

vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => {
return Promise.resolve(<Response>(<unknown>{
text: () => Promise.resolve('<html><head></head><body>Test</body></html>'),
ok: true,
headers: new Headers()
}));
});
const actualFrameUrl = await new Promise((resolve) => {
element.src = 'https://localhost:8080/iframe.html';
const firstLoad = (): void => {
element.removeEventListener('load', firstLoad);
element.addEventListener('load', () => {
resolve(page.mainFrame.childFrames[0].url);
});
element.removeAttribute('src');
};
element.addEventListener('load', firstLoad);
document.body.appendChild(element);
});
expect(actualFrameUrl).toBe('about:blank');
});
});

describe('get contentDocument()', () => {
it('Returns content document for "about:blank".', () => {
element.src = 'about:blank';
expect(element.contentDocument).toBe(null);
document.body.appendChild(element);
expect(element.contentWindow?.parent === window).toBe(true);
expect(element.contentDocument?.documentElement.innerHTML).toBe('<head></head><body></body>');
});
});
Expand Down

0 comments on commit 8d125f6

Please sign in to comment.