feat: [extensions] support extension.getBackgroundPage (#21951)
* feat: [extensions] support extension.getBackgroundPage * cleanup * how does c++ * tests * test for runtime.getBackgroundPage too
This commit is contained in:
		
					parent
					
						
							
								eca1dd7f8b
							
						
					
				
			
			
				commit
				
					
						9107157073
					
				
			
		
					 16 changed files with 217 additions and 23 deletions
				
			
		|  | @ -186,6 +186,63 @@ const base::FilePath::StringPieceType kPathDelimiter = FILE_PATH_LITERAL(";"); | |||
| const base::FilePath::StringPieceType kPathDelimiter = FILE_PATH_LITERAL(":"); | ||||
| #endif | ||||
| 
 | ||||
| #if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS) | ||||
| // Used by the GetPrivilegeRequiredByUrl() and GetProcessPrivilege() functions
 | ||||
| // below.  Extension, and isolated apps require different privileges to be
 | ||||
| // granted to their RenderProcessHosts.  This classification allows us to make
 | ||||
| // sure URLs are served by hosts with the right set of privileges.
 | ||||
| enum RenderProcessHostPrivilege { | ||||
|   PRIV_NORMAL, | ||||
|   PRIV_HOSTED, | ||||
|   PRIV_ISOLATED, | ||||
|   PRIV_EXTENSION, | ||||
| }; | ||||
| 
 | ||||
| RenderProcessHostPrivilege GetPrivilegeRequiredByUrl( | ||||
|     const GURL& url, | ||||
|     extensions::ExtensionRegistry* registry) { | ||||
|   // Default to a normal renderer cause it is lower privileged. This should only
 | ||||
|   // occur if the URL on a site instance is either malformed, or uninitialized.
 | ||||
|   // If it is malformed, then there is no need for better privileges anyways.
 | ||||
|   // If it is uninitialized, but eventually settles on being an a scheme other
 | ||||
|   // than normal webrenderer, the navigation logic will correct us out of band
 | ||||
|   // anyways.
 | ||||
|   if (!url.is_valid()) | ||||
|     return PRIV_NORMAL; | ||||
| 
 | ||||
|   if (!url.SchemeIs(extensions::kExtensionScheme)) | ||||
|     return PRIV_NORMAL; | ||||
| 
 | ||||
|   return PRIV_EXTENSION; | ||||
| } | ||||
| 
 | ||||
| RenderProcessHostPrivilege GetProcessPrivilege( | ||||
|     content::RenderProcessHost* process_host, | ||||
|     extensions::ProcessMap* process_map, | ||||
|     extensions::ExtensionRegistry* registry) { | ||||
|   std::set<std::string> extension_ids = | ||||
|       process_map->GetExtensionsInProcess(process_host->GetID()); | ||||
|   if (extension_ids.empty()) | ||||
|     return PRIV_NORMAL; | ||||
| 
 | ||||
|   return PRIV_EXTENSION; | ||||
| } | ||||
| 
 | ||||
| const extensions::Extension* GetEnabledExtensionFromEffectiveURL( | ||||
|     content::BrowserContext* context, | ||||
|     const GURL& effective_url) { | ||||
|   if (!effective_url.SchemeIs(extensions::kExtensionScheme)) | ||||
|     return nullptr; | ||||
| 
 | ||||
|   extensions::ExtensionRegistry* registry = | ||||
|       extensions::ExtensionRegistry::Get(context); | ||||
|   if (!registry) | ||||
|     return nullptr; | ||||
| 
 | ||||
|   return registry->enabled_extensions().GetByID(effective_url.host()); | ||||
| } | ||||
| #endif  // BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS)
 | ||||
| 
 | ||||
| }  // namespace
 | ||||
| 
 | ||||
| // static
 | ||||
|  | @ -760,6 +817,40 @@ void ElectronBrowserClient::SiteInstanceGotProcess( | |||
| #endif  // BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS)
 | ||||
| } | ||||
| 
 | ||||
| bool ElectronBrowserClient::IsSuitableHost( | ||||
|     content::RenderProcessHost* process_host, | ||||
|     const GURL& site_url) { | ||||
| #if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS) | ||||
|   auto* browser_context = process_host->GetBrowserContext(); | ||||
|   extensions::ExtensionRegistry* registry = | ||||
|       extensions::ExtensionRegistry::Get(browser_context); | ||||
|   extensions::ProcessMap* process_map = | ||||
|       extensions::ProcessMap::Get(browser_context); | ||||
| 
 | ||||
|   // Otherwise, just make sure the process privilege matches the privilege
 | ||||
|   // required by the site.
 | ||||
|   RenderProcessHostPrivilege privilege_required = | ||||
|       GetPrivilegeRequiredByUrl(site_url, registry); | ||||
|   return GetProcessPrivilege(process_host, process_map, registry) == | ||||
|          privilege_required; | ||||
| #else | ||||
|   return content::ContentBrowserClient::IsSuitableHost(process_host, site_url); | ||||
| #endif | ||||
| } | ||||
| 
 | ||||
| bool ElectronBrowserClient::ShouldUseProcessPerSite( | ||||
|     content::BrowserContext* browser_context, | ||||
|     const GURL& effective_url) { | ||||
| #if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS) | ||||
|   const extensions::Extension* extension = | ||||
|       GetEnabledExtensionFromEffectiveURL(browser_context, effective_url); | ||||
|   return extension != nullptr; | ||||
| #else | ||||
|   return content::ContentBrowserClient::ShouldUseProcessPerSite(browser_context, | ||||
|                                                                 effective_url); | ||||
| #endif | ||||
| } | ||||
| 
 | ||||
| void ElectronBrowserClient::SiteInstanceDeleting( | ||||
|     content::SiteInstance* site_instance) { | ||||
|   // We are storing weak_ptr, is it fundamental to maintain the map up-to-date
 | ||||
|  |  | |||
|  | @ -221,6 +221,10 @@ class ElectronBrowserClient : public content::ContentBrowserClient, | |||
|       bool first_auth_attempt, | ||||
|       LoginAuthRequiredCallback auth_required_callback) override; | ||||
|   void SiteInstanceGotProcess(content::SiteInstance* site_instance) override; | ||||
|   bool IsSuitableHost(content::RenderProcessHost* process_host, | ||||
|                       const GURL& site_url) override; | ||||
|   bool ShouldUseProcessPerSite(content::BrowserContext* browser_context, | ||||
|                                const GURL& effective_url) override; | ||||
| 
 | ||||
|   // content::RenderProcessHostObserver:
 | ||||
|   void RenderProcessHostDestroyed(content::RenderProcessHost* host) override; | ||||
|  |  | |||
|  | @ -9,6 +9,10 @@ | |||
|     "extension_types": ["extension"], | ||||
|     "contexts": ["blessed_extension"] | ||||
|   }, | ||||
|   "extension.getBackgroundPage": { | ||||
|     "contexts": ["blessed_extension"], | ||||
|     "disallow_for_service_workers": true | ||||
|   }, | ||||
|   "extension.getURL": { | ||||
|     "contexts": ["blessed_extension", "unblessed_extension", "content_script"] | ||||
|   } | ||||
|  |  | |||
|  | @ -12,6 +12,20 @@ | |||
|     "properties": { | ||||
|     }, | ||||
|     "functions": [ | ||||
|       { | ||||
|         "name": "getBackgroundPage", | ||||
|         "nocompile": true, | ||||
|         "type": "function", | ||||
|         "description": "Returns the JavaScript 'window' object for the background page running inside the current extension. Returns null if the extension has no background page.", | ||||
|         "parameters": [], | ||||
|         "returns": { | ||||
|             "type": "object", | ||||
|             "optional": true, | ||||
|             "name": "backgroundPageGlobal", | ||||
|             "isInstanceOf": "Window", | ||||
|             "additionalProperties": { "type": "any" } | ||||
|          } | ||||
|       }, | ||||
|       { | ||||
|         "name": "getURL", | ||||
|         "deprecated": "Please use $(ref:runtime.getURL).", | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| [ | ||||
|   { | ||||
|     "namespace": "tabs", | ||||
|     "description": "Use the <code>chrome.tabs</code> API to interact with the browser's tab system. You can use this API to create, modify, and rearrange tabs in the browser.", | ||||
|     "functions": [ | ||||
|       { | ||||
|         "name": "executeScript", | ||||
|  |  | |||
|  | @ -31,8 +31,8 @@ ifdescribe(process.electronBinding('features').isExtensionsEnabled())('chrome ex | |||
|     // extension registry is redirected to the main session. so installing an
 | ||||
|     // extension in an in-memory session results in it being installed in the
 | ||||
|     // default session.
 | ||||
|     const customSession = session.fromPartition(`persist:${require('uuid').v4()}`); | ||||
|     (customSession as any).loadExtension(path.join(fixtures, 'extensions', 'red-bg')) | ||||
|     const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|     await customSession.loadExtension(path.join(fixtures, 'extensions', 'red-bg')) | ||||
|     const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } }) | ||||
|     await w.loadURL(url) | ||||
|     const bg = await w.webContents.executeJavaScript('document.documentElement.style.backgroundColor') | ||||
|  | @ -41,14 +41,14 @@ ifdescribe(process.electronBinding('features').isExtensionsEnabled())('chrome ex | |||
| 
 | ||||
|   it('removes an extension', async () => { | ||||
|     const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|     const { id } = await (customSession as any).loadExtension(path.join(fixtures, 'extensions', 'red-bg')) | ||||
|     const { id } = await customSession.loadExtension(path.join(fixtures, 'extensions', 'red-bg')) | ||||
|     { | ||||
|       const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } }) | ||||
|       await w.loadURL(url) | ||||
|       const bg = await w.webContents.executeJavaScript('document.documentElement.style.backgroundColor') | ||||
|       expect(bg).to.equal('red') | ||||
|     } | ||||
|     (customSession as any).removeExtension(id) | ||||
|     customSession.removeExtension(id) | ||||
|     { | ||||
|       const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } }) | ||||
|       await w.loadURL(url) | ||||
|  | @ -59,21 +59,21 @@ ifdescribe(process.electronBinding('features').isExtensionsEnabled())('chrome ex | |||
| 
 | ||||
|   it('lists loaded extensions in getAllExtensions', async () => { | ||||
|     const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|     const e = await (customSession as any).loadExtension(path.join(fixtures, 'extensions', 'red-bg')) | ||||
|     expect((customSession as any).getAllExtensions()).to.deep.equal([e]); | ||||
|     (customSession as any).removeExtension(e.id) | ||||
|     expect((customSession as any).getAllExtensions()).to.deep.equal([]) | ||||
|     const e = await customSession.loadExtension(path.join(fixtures, 'extensions', 'red-bg')) | ||||
|     expect(customSession.getAllExtensions()).to.deep.equal([e]) | ||||
|     customSession.removeExtension(e.id) | ||||
|     expect(customSession.getAllExtensions()).to.deep.equal([]) | ||||
|   }) | ||||
| 
 | ||||
|   it('gets an extension by id', async () => { | ||||
|     const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|     const e = await (customSession as any).loadExtension(path.join(fixtures, 'extensions', 'red-bg')) | ||||
|     expect((customSession as any).getExtension(e.id)).to.deep.equal(e) | ||||
|     const e = await customSession.loadExtension(path.join(fixtures, 'extensions', 'red-bg')) | ||||
|     expect(customSession.getExtension(e.id)).to.deep.equal(e) | ||||
|   }) | ||||
| 
 | ||||
|   it('confines an extension to the session it was loaded in', async () => { | ||||
|     const customSession = session.fromPartition(`persist:${require('uuid').v4()}`); | ||||
|     (customSession as any).loadExtension(path.join(fixtures, 'extensions', 'red-bg')) | ||||
|     const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|     customSession.loadExtension(path.join(fixtures, 'extensions', 'red-bg')) | ||||
|     const w = new BrowserWindow({ show: false }) // not in the session
 | ||||
|     await w.loadURL(url) | ||||
|     const bg = await w.webContents.executeJavaScript('document.documentElement.style.backgroundColor') | ||||
|  | @ -83,8 +83,8 @@ ifdescribe(process.electronBinding('features').isExtensionsEnabled())('chrome ex | |||
|   describe('chrome.runtime', () => { | ||||
|     let content: any | ||||
|     before(async () => { | ||||
|       const customSession = session.fromPartition(`persist:${require('uuid').v4()}`); | ||||
|       (customSession as any).loadExtension(path.join(fixtures, 'extensions', 'chrome-runtime')) | ||||
|       const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|       customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-runtime')) | ||||
|       const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } }) | ||||
|       try { | ||||
|         await w.loadURL(url) | ||||
|  | @ -107,8 +107,8 @@ ifdescribe(process.electronBinding('features').isExtensionsEnabled())('chrome ex | |||
| 
 | ||||
|   describe('chrome.storage', () => { | ||||
|     it('stores and retrieves a key', async () => { | ||||
|       const customSession = session.fromPartition(`persist:${require('uuid').v4()}`); | ||||
|       (customSession as any).loadExtension(path.join(fixtures, 'extensions', 'chrome-storage')) | ||||
|       const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|       await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-storage')) | ||||
|       const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } }) | ||||
|       try { | ||||
|         const p = emittedOnce(ipcMain, 'storage-success') | ||||
|  | @ -124,7 +124,7 @@ ifdescribe(process.electronBinding('features').isExtensionsEnabled())('chrome ex | |||
|   describe('chrome.tabs', () => { | ||||
|     it('executeScript', async () => { | ||||
|       const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|       ;(customSession as any).loadExtension(path.join(fixtures, 'extensions', 'chrome-api')) | ||||
|       await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-api')) | ||||
|       const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } }) | ||||
|       await w.loadURL(url) | ||||
| 
 | ||||
|  | @ -139,7 +139,7 @@ ifdescribe(process.electronBinding('features').isExtensionsEnabled())('chrome ex | |||
| 
 | ||||
|     it('sendMessage receives the response', async function () { | ||||
|       const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|       ;(customSession as any).loadExtension(path.join(fixtures, 'extensions', 'chrome-api')) | ||||
|       await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-api')) | ||||
|       const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } }) | ||||
|       await w.loadURL(url) | ||||
| 
 | ||||
|  | @ -157,7 +157,7 @@ ifdescribe(process.electronBinding('features').isExtensionsEnabled())('chrome ex | |||
|   describe('background pages', () => { | ||||
|     it('loads a lazy background page when sending a message', async () => { | ||||
|       const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|       ;(customSession as any).loadExtension(path.join(fixtures, 'extensions', 'lazy-background-page')) | ||||
|       await customSession.loadExtension(path.join(fixtures, 'extensions', 'lazy-background-page')) | ||||
|       const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } }) | ||||
|       try { | ||||
|         w.loadURL(url) | ||||
|  | @ -170,6 +170,33 @@ ifdescribe(process.electronBinding('features').isExtensionsEnabled())('chrome ex | |||
|         w.destroy() | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('can use extension.getBackgroundPage from a ui page', async () => { | ||||
|       const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|       const { id } = await customSession.loadExtension(path.join(fixtures, 'extensions', 'lazy-background-page')) | ||||
|       const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } }) | ||||
|       await w.loadURL(`chrome-extension://${id}/page-get-background.html`) | ||||
|       const receivedMessage = await w.webContents.executeJavaScript(`window.completionPromise`) | ||||
|       expect(receivedMessage).to.deep.equal({ some: 'message' }) | ||||
|     }) | ||||
| 
 | ||||
|     it('can use extension.getBackgroundPage from a ui page', async () => { | ||||
|       const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|       const { id } = await customSession.loadExtension(path.join(fixtures, 'extensions', 'lazy-background-page')) | ||||
|       const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } }) | ||||
|       await w.loadURL(`chrome-extension://${id}/page-get-background.html`) | ||||
|       const receivedMessage = await w.webContents.executeJavaScript(`window.completionPromise`) | ||||
|       expect(receivedMessage).to.deep.equal({ some: 'message' }) | ||||
|     }) | ||||
| 
 | ||||
|     it('can use runtime.getBackgroundPage from a ui page', async () => { | ||||
|       const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|       const { id } = await customSession.loadExtension(path.join(fixtures, 'extensions', 'lazy-background-page')) | ||||
|       const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } }) | ||||
|       await w.loadURL(`chrome-extension://${id}/page-runtime-get-background.html`) | ||||
|       const receivedMessage = await w.webContents.executeJavaScript(`window.completionPromise`) | ||||
|       expect(receivedMessage).to.deep.equal({ some: 'message' }) | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe('devtools extensions', () => { | ||||
|  | @ -201,8 +228,8 @@ ifdescribe(process.electronBinding('features').isExtensionsEnabled())('chrome ex | |||
|     } | ||||
| 
 | ||||
|     it('loads a devtools extension', async () => { | ||||
|       const customSession = session.fromPartition(`persist:${require('uuid').v4()}`); | ||||
|       (customSession as any).loadExtension(path.join(fixtures, 'extensions', 'devtools-extension')) | ||||
|       const customSession = session.fromPartition(`persist:${require('uuid').v4()}`) | ||||
|       customSession.loadExtension(path.join(fixtures, 'extensions', 'devtools-extension')) | ||||
|       const w = new BrowserWindow({ show: true, webPreferences: { session: customSession, nodeIntegration: true } }) | ||||
|       await w.loadURL('data:text/html,hello') | ||||
|       w.webContents.openDevTools() | ||||
|  | @ -213,8 +240,8 @@ ifdescribe(process.electronBinding('features').isExtensionsEnabled())('chrome ex | |||
| 
 | ||||
|   describe('deprecation shims', () => { | ||||
|     afterEach(() => { | ||||
|       (session.defaultSession as any).getAllExtensions().forEach((e: any) => { | ||||
|         (session.defaultSession as any).removeExtension(e.id) | ||||
|       session.defaultSession.getAllExtensions().forEach((e: any) => { | ||||
|         session.defaultSession.removeExtension(e.id) | ||||
|       }) | ||||
|     }) | ||||
| 
 | ||||
|  | @ -387,6 +414,30 @@ ifdescribe(process.electronBinding('features').isExtensionsEnabled())('chrome ex | |||
|     generateTests(true, false) | ||||
|     generateTests(true, true) | ||||
|   }) | ||||
| 
 | ||||
|   describe('extension ui pages', () => { | ||||
|     afterEach(() => { | ||||
|       session.defaultSession.getAllExtensions().forEach(e => { | ||||
|         session.defaultSession.removeExtension(e.id) | ||||
|       }) | ||||
|     }) | ||||
| 
 | ||||
|     it('loads a ui page of an extension', async () => { | ||||
|       const { id } = await session.defaultSession.loadExtension(path.join(fixtures, 'extensions', 'ui-page')) | ||||
|       const w = new BrowserWindow({ show: false }) | ||||
|       await w.loadURL(`chrome-extension://${id}/bare-page.html`) | ||||
|       const textContent = await w.webContents.executeJavaScript(`document.body.textContent`) | ||||
|       expect(textContent).to.equal('ui page loaded ok\n') | ||||
|     }) | ||||
| 
 | ||||
|     it('can load resources', async () => { | ||||
|       const { id } = await session.defaultSession.loadExtension(path.join(fixtures, 'extensions', 'ui-page')) | ||||
|       const w = new BrowserWindow({ show: false }) | ||||
|       await w.loadURL(`chrome-extension://${id}/page-script-load.html`) | ||||
|       const textContent = await w.webContents.executeJavaScript(`document.body.textContent`) | ||||
|       expect(textContent).to.equal('script loaded ok\n') | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
| 
 | ||||
| ifdescribe(!process.electronBinding('features').isExtensionsEnabled())('chrome extensions', () => { | ||||
|  |  | |||
|  | @ -1,4 +1,5 @@ | |||
| /* eslint-disable no-undef */ | ||||
| chrome.runtime.onMessage.addListener((message, sender, reply) => { | ||||
|   window.receivedMessage = message | ||||
|   reply({ message, sender }) | ||||
| }) | ||||
|  |  | |||
|  | @ -0,0 +1,7 @@ | |||
| /* global chrome */ | ||||
| window.completionPromise = new Promise((resolve) => { | ||||
|   window.completionPromiseResolve = resolve | ||||
| }) | ||||
| chrome.runtime.sendMessage({ some: 'message' }, (response) => { | ||||
|   window.completionPromiseResolve(chrome.extension.getBackgroundPage().receivedMessage) | ||||
| }) | ||||
|  | @ -0,0 +1 @@ | |||
| <script src="get-background-page.js"></script> | ||||
|  | @ -0,0 +1 @@ | |||
| <script src="runtime-get-background-page.js"></script> | ||||
|  | @ -0,0 +1,9 @@ | |||
| /* global chrome */ | ||||
| window.completionPromise = new Promise((resolve) => { | ||||
|   window.completionPromiseResolve = resolve | ||||
| }) | ||||
| chrome.runtime.sendMessage({ some: 'message' }, (response) => { | ||||
|   chrome.runtime.getBackgroundPage((bgPage) => { | ||||
|     window.completionPromiseResolve(bgPage.receivedMessage) | ||||
|   }) | ||||
| }) | ||||
							
								
								
									
										2
									
								
								spec-main/fixtures/extensions/ui-page/bare-page.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								spec-main/fixtures/extensions/ui-page/bare-page.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,2 @@ | |||
| <!doctype html> | ||||
| <body>ui page loaded ok</body> | ||||
							
								
								
									
										5
									
								
								spec-main/fixtures/extensions/ui-page/manifest.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								spec-main/fixtures/extensions/ui-page/manifest.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| { | ||||
|   "name": "ui-page", | ||||
|   "version": "1.0", | ||||
|   "manifest_version": 2 | ||||
| } | ||||
|  | @ -0,0 +1 @@ | |||
| <script src="get-background-page.js"></script> | ||||
|  | @ -0,0 +1 @@ | |||
| <script src="script.js"></script> | ||||
							
								
								
									
										1
									
								
								spec-main/fixtures/extensions/ui-page/script.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								spec-main/fixtures/extensions/ui-page/script.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| document.write('script loaded ok') | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Jeremy Apthorp
				Jeremy Apthorp