Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [#1398] Add support for iframe srcdoc #1399

Merged
merged 3 commits into from
May 6, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ export default class BrowserFrameNavigator {
const width = frame.window.innerWidth;
const height = frame.window.innerHeight;
const devicePixelRatio = frame.window.devicePixelRatio;
const parentWindow = frame.window.parent !== frame.window ? frame.window.parent : null;
const topWindow = frame.window.top !== frame.window ? frame.window.top : null;

for (const childFrame of frame.childFrames) {
BrowserFrameFactory.destroyFrame(childFrame);
Expand All @@ -104,6 +106,8 @@ export default class BrowserFrameNavigator {
frame[PropertySymbol.asyncTaskManager] = new AsyncTaskManager();

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

if (referrer) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,14 @@ 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][PropertySymbol.attributes]['srcdoc']?.value === undefined &&
item[PropertySymbol.value] &&
item[PropertySymbol.value] !== replacedAttribute?.[PropertySymbol.value]
) {
Expand All @@ -70,6 +76,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 @@ -19,6 +19,7 @@ export default class HTMLIFrameElementPageLoader {
#contentWindowContainer: { window: BrowserWindow | CrossOriginBrowserWindow | null };
#browserParentFrame: IBrowserFrame;
#browserIFrame: IBrowserFrame;
#srcdoc: string | null = null;

/**
* Constructor.
Expand All @@ -44,15 +45,43 @@ export default class HTMLIFrameElementPageLoader {
*/
public loadPage(): void {
if (!this.#element[PropertySymbol.isConnected]) {
if (this.#browserIFrame) {
BrowserFrameFactory.destroyFrame(this.#browserIFrame);
this.#browserIFrame = null;
}
this.#contentWindowContainer.window = null;
this.unloadPage();
return;
}

const srcdoc = this.#element.getAttribute('srcdoc');
const window = this.#element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow];

if (srcdoc !== null) {
if (this.#srcdoc === srcdoc) {
return;
}

this.unloadPage();

this.#browserIFrame = BrowserFrameFactory.createChildFrame(this.#browserParentFrame);
this.#browserIFrame.url = 'about:srcdoc';

this.#contentWindowContainer.window = this.#browserIFrame.window;

(<BrowserWindow>this.#browserIFrame.window.top) = this.#browserParentFrame.window.top;
(<BrowserWindow>this.#browserIFrame.window.parent) = this.#browserParentFrame.window;

this.#browserIFrame.window.document.open();
this.#browserIFrame.window.document.write(srcdoc);

this.#srcdoc = srcdoc;

this.#element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].requestAnimationFrame(
() => this.#element.dispatchEvent(new Event('load'))
);
return;
}

if (this.#srcdoc !== null) {
this.unloadPage();
}

const originURL = this.#browserParentFrame.window.location;
const targetURL = BrowserFrameURL.getRelativeURL(this.#browserParentFrame, this.#element.src);

Expand Down Expand Up @@ -105,5 +134,6 @@ export default class HTMLIFrameElementPageLoader {
this.#browserIFrame = null;
}
this.#contentWindowContainer.window = null;
this.#srcdoc = null;
}
}
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
Loading