You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/10/13 21:35:18 UTC

(grails-core) 01/01: Add unit tests for Grails layout capture and response buffering.

This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch test/gsp-layout-coverage-7.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 45828c7c7f62004079fc85caa12a0a963448573f
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Tue Oct 13 17:34:10 2026 -0400

    Add unit tests for Grails layout capture and response buffering.
    
    Cover page wrappers, HTML parsing, decorators, and layout tag capture.
    
    Assisted-by: sisyphus-grok:grok-4.7
---
 .../web/layout/GSPGrailsLayoutPageSpec.groovy      | 155 ++++++++++++++
 .../web/layout/GrailsHTMLPageParserSpec.groovy     |  81 ++++++++
 .../web/layout/GrailsLayoutTagLibSpec.groovy       |  83 ++++++++
 .../layout/GrailsPageResponseWrapperSpec.groovy    | 223 +++++++++++++++++++++
 .../layout/GrailsViewBufferingResponseSpec.groovy  | 146 ++++++++++++++
 .../grails/web/layout/LayoutDecoratorSpec.groovy   | 121 +++++++++++
 .../web/layout/RenderGrailsLayoutTagLibSpec.groovy |  92 +++++++++
 7 files changed, 901 insertions(+)

diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GSPGrailsLayoutPageSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GSPGrailsLayoutPageSpec.groovy
new file mode 100644
index 0000000000..f439cde89f
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GSPGrailsLayoutPageSpec.groovy
@@ -0,0 +1,155 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import org.grails.buffer.StreamCharBuffer
+import spock.lang.Specification
+
+class GSPGrailsLayoutPageSpec extends Specification {
+
+    void 'empty page writes no content and is unused'() {
+        given:
+        def page = new GSPGrailsLayoutPage()
+        def writer = new StringWriter()
+
+        when:
+        page.writeHead(writer)
+        page.writeBody(writer)
+        page.writePage(writer)
+
+        then:
+        writer.toString() == ''
+        page.getHead() == null
+        page.getBody() == null
+        page.getPage() == null
+        page.getTitleBuffer() == null
+        page.getContentBuffer('page.sidebar') == null
+        !page.isUsed()
+        !page.isTitleCaptured()
+    }
+
+    void 'captured buffers expose head body and original page separately'() {
+        given:
+        def page = new GSPGrailsLayoutPage(renderingLayout)
+        def original = '<html><head>head</head><body>body</body></html>'
+        page.setPageBuffer(buffer(original))
+        page.setHeadBuffer(buffer('head'))
+        page.setBodyBuffer(buffer('body'))
+        def head = new StringWriter()
+        def body = new StringWriter()
+        def full = new StringWriter()
+
+        when:
+        page.writeHead(head)
+        page.writeBody(body)
+        page.writeOriginal(full)
+
+        then:
+        page.isUsed()
+        page.getHead() == head.toString()
+        head.toString() == 'head'
+        page.getBody() == body.toString()
+        body.toString() == 'body'
+        page.getPage() == full.toString()
+        full.toString() == original
+        page.originalLength() == original.length()
+
+        where:
+        renderingLayout << [false, true]
+    }
+
+    void 'body output falls back to the whole page when no body has been captured'() {
+        given:
+        def page = new GSPGrailsLayoutPage()
+        page.setPageBuffer(buffer('fragment without a body tag'))
+        def writer = new StringWriter()
+
+        when:
+        page.writeBody(writer)
+
+        then:
+        writer.toString() == 'fragment without a body tag'
+        !page.isUsed()
+    }
+
+    void 'captured title is removed from head output but its property is retained'() {
+        given:
+        def page = new GSPGrailsLayoutPage()
+        page.setHeadBuffer(buffer('<TITLE class="main">Title</TITLE><meta name="layout" content="main">'))
+        page.addProperty('title', 'Title')
+        page.setTitleCaptured(true)
+        def writer = new StringWriter()
+
+        when:
+        page.writeHead(writer)
+
+        then:
+        writer.toString() == '<meta name="layout" content="main">'
+        page.title == 'Title'
+        page.isTitleCaptured()
+    }
+
+    void 'content and object properties mark the page as used'() {
+        given:
+        def page = new GSPGrailsLayoutPage()
+        def sidebar = buffer('Sidebar')
+
+        when:
+        page.addProperty('page.count', Integer.valueOf(3))
+        page.setContentBuffer('sidebar', sidebar)
+
+        then:
+        page.isUsed()
+        page.getProperty('page.count') == '3'
+        page.getProperty('page.sidebar') == ''
+        page.getContentBuffer('page.sidebar').is(sidebar)
+        page.getContentBuffer('page.missing') == null
+        GSPGrailsLayoutPage.content2htmlPage(page).is(page)
+    }
+
+    void 'reset clears captured content and flags so a page can be reused'() {
+        given:
+        def page = new GSPGrailsLayoutPage()
+        page.setHeadBuffer(buffer('head'))
+        page.setBodyBuffer(buffer('body'))
+        page.setPageBuffer(buffer('page'))
+        page.setTitleBuffer(buffer('title'))
+        page.setTitleCaptured(true)
+        page.setContentBuffer('sidebar', buffer('sidebar'))
+
+        when:
+        page.reset()
+
+        then:
+        !page.isUsed()
+        !page.isTitleCaptured()
+        page.getHead() == null
+        page.getBody() == null
+        page.getPage() == null
+        page.getTitleBuffer() == null
+        page.getContentBuffer('page.sidebar') == null
+    }
+
+    private static StreamCharBuffer buffer(String text) {
+        def buffer = new StreamCharBuffer()
+        buffer.getWriter().write(text)
+        buffer
+    }
+}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsHTMLPageParserSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsHTMLPageParserSpec.groovy
new file mode 100644
index 0000000000..c83e75e33b
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsHTMLPageParserSpec.groovy
@@ -0,0 +1,81 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import com.opensymphony.sitemesh.Content
+import spock.lang.Specification
+
+class GrailsHTMLPageParserSpec extends Specification {
+
+    void 'HTML parser extracts title head body and layout properties'() {
+        given:
+        def html = '<html lang="en"><head><title>Title</title><meta name="layout" content="main"></head>' +
+                '<body class="content"><p>Hello</p></body></html>'
+
+        when:
+        def page = new GrailsHTMLPageParser().parse(html.toCharArray())
+
+        then:
+        page.title == 'Title'
+        page.head == '<meta name="layout" content="main">'
+        page.body == '<p>Hello</p>'
+        page.getProperty('lang') == 'en'
+        page.getProperty('body.class') == 'content'
+        page.getProperty('meta.layout') == 'main'
+    }
+
+    void 'titles inside XML do not replace the HTML title'() {
+        given:
+        def html = '<html><head><title>Page title</title></head>' +
+                '<body><xml><book><title>Book title</title></book></xml>Body</body></html>'
+
+        when:
+        def page = new GrailsHTMLPageParser().parse(html.toCharArray())
+
+        then:
+        page.title == 'Page title'
+        page.body.contains('<title>Book title</title>')
+        page.body.endsWith('Body')
+    }
+
+    void 'parsed content exposes original head and body through the public Content interface'() {
+        given:
+        def html = '<html><head><title>Title</title><meta name="layout" content="main"></head><body>Body</body></html>'
+        Content content = new GrailsHTMLPageParser().parseContent(html.toCharArray())
+        def original = new StringWriter()
+        def head = new StringWriter()
+        def body = new StringWriter()
+
+        when:
+        content.writeOriginal(original)
+        content.writeHead(head)
+        content.writeBody(body)
+        content.addProperty('page.extra', 'value')
+
+        then:
+        original.toString() == html
+        content.originalLength() == html.length()
+        head.toString() == '<meta name="layout" content="main">'
+        body.toString() == 'Body'
+        content.title == 'Title'
+        content.getProperty('page.extra') == 'value'
+        GSPGrailsLayoutPage.content2htmlPage(content).body == 'Body'
+    }
+}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsLayoutTagLibSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsLayoutTagLibSpec.groovy
new file mode 100644
index 0000000000..46d3ec7c81
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsLayoutTagLibSpec.groovy
@@ -0,0 +1,83 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import grails.testing.web.taglib.TagLibUnitTest
+import org.grails.plugins.web.taglib.GrailsLayoutTagLib
+import spock.lang.Specification
+
+class GrailsLayoutTagLibSpec extends Specification implements TagLibUnitTest<GrailsLayoutTagLib> {
+
+    GSPGrailsLayoutPage page
+
+    void setup() {
+        page = new GSPGrailsLayoutPage()
+        request.setAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE, page)
+    }
+
+    void 'capture tags render HTML and store head body and title on the page'() {
+        when:
+        def output = applyTemplate('<grailsLayout:captureHead><grailsLayout:captureTitle>Title</grailsLayout:captureTitle>' +
+                '<meta name="layout" content="main"></grailsLayout:captureHead>' +
+                '<grailsLayout:captureBody class="content">Body</grailsLayout:captureBody>')
+
+        then:
+        output == '<head><title>Title</title><meta name="layout" content="main"></head><body class="content">Body</body>'
+        page.title == 'Title'
+        page.isTitleCaptured()
+        page.head.contains('<meta name="layout" content="main">')
+        page.body == 'Body'
+        page.getProperty('body.class') == 'content'
+        page.isUsed()
+    }
+
+    void 'meta capture records both original and lowercase property names'() {
+        when:
+        def output = applyTemplate('<grailsLayout:captureMeta name="Description" content="Details"/>' +
+                '<grailsLayout:captureMeta http-equiv="Content-Type" content="text/html"/>')
+
+        then:
+        output == '<meta name="Description" content="Details"><meta http-equiv="Content-Type" content="text/html">'
+        page.getProperty('meta.Description') == 'Details'
+        page.getProperty('meta.description') == 'Details'
+        page.getProperty('meta.http-equiv.Content-Type') == 'text/html'
+        page.getProperty('meta.http-equiv.content-type') == 'text/html'
+    }
+
+    void 'parameter and content capture populate the page without writing the captured component'() {
+        when:
+        def output = applyTemplate('<grailsLayout:parameter name="theme" value="dark"/>' +
+                '<grailsLayout:captureContent tag="sidebar">Sidebar</grailsLayout:captureContent>')
+
+        then:
+        output == ''
+        page.getProperty('page.theme') == 'dark'
+        page.getContentBuffer('page.sidebar').toString() == 'Sidebar'
+    }
+
+    void 'capture tags still render without a layout page'() {
+        given:
+        request.removeAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE)
+
+        expect:
+        applyTemplate('<grailsLayout:captureTitle/><grailsLayout:captureBody>Body</grailsLayout:captureBody>') ==
+                '<title></title><body>Body</body>'
+    }
+}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsPageResponseWrapperSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsPageResponseWrapperSpec.groovy
new file mode 100644
index 0000000000..3e680a039d
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsPageResponseWrapperSpec.groovy
@@ -0,0 +1,223 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import com.opensymphony.module.sitemesh.PageParserSelector
+import org.springframework.mock.web.MockHttpServletRequest
+import org.springframework.mock.web.MockHttpServletResponse
+import spock.lang.Specification
+
+class GrailsPageResponseWrapperSpec extends Specification {
+
+    MockHttpServletRequest request = new MockHttpServletRequest()
+    MockHttpServletResponse response = new MockHttpServletResponse()
+    PageParserSelector parserSelector = Stub(PageParserSelector) {
+        shouldParsePage('text/html') >> true
+        getPageParser('text/html') >> new GrailsHTMLPageParser()
+    }
+
+    void 'HTML written through the writer is buffered and parsed without committing the response'() {
+        given:
+        def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+        def html = '<html><head><title>Layout</title></head><body>Hello</body></html>'
+        wrapper.setContentType('text/html;charset=UTF-8')
+
+        when:
+        wrapper.getWriter().write(html)
+        wrapper.getWriter().flush()
+        wrapper.flushBuffer()
+        wrapper.setContentLength(html.length())
+        wrapper.setHeader('Content-Length', '200')
+        wrapper.addHeader('content-length', '300')
+        wrapper.setHeader('X-Layout', 'buffered')
+        wrapper.addHeader('X-Layout', 'parsed')
+
+        then:
+        wrapper.isGrailsLayoutActive()
+        !wrapper.isUsingStream()
+        new String(wrapper.getContents()) == html
+        wrapper.getPage().title == 'Layout'
+        wrapper.getPage().body == 'Hello'
+        response.contentAsString == ''
+        !response.committed
+        response.getHeader('Content-Length') == null
+        response.getHeaders('X-Layout') == ['buffered', 'parsed']
+    }
+
+    void 'HTML output stream uses the declared encoding'() {
+        given:
+        def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+        def html = '<html><body>caf\u00e9</body></html>'
+        wrapper.setHeader('Content-Type', 'text/html;charset=UTF-8')
+
+        when:
+        wrapper.getOutputStream().write(html.getBytes('UTF-8'))
+        def page = wrapper.getPage()
+
+        then:
+        wrapper.isUsingStream()
+        page.page == html
+        page.body == 'caf\u00e9'
+        response.contentAsByteArray.length == 0
+    }
+
+    void 'buffering disallows switching between writer and output stream'() {
+        given:
+        def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+        wrapper.setContentType('text/html')
+
+        when:
+        if (streamFirst) {
+            wrapper.getOutputStream().write(65)
+            wrapper.getWriter().write('B')
+        } else {
+            wrapper.getWriter().write('A')
+            wrapper.getOutputStream().write(66)
+        }
+
+        then:
+        thrown(IllegalStateException)
+
+        where:
+        streamFirst << [true, false]
+    }
+
+    void 'unparseable content is passed through with content length and flush'() {
+        given:
+        def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+
+        when:
+        wrapper.addHeader('Content-Type', 'text/plain')
+        wrapper.setContentLength(5)
+        wrapper.setHeader('Content-Length', '5')
+        wrapper.getWriter().write('plain')
+        wrapper.flushBuffer()
+
+        then:
+        !wrapper.isGrailsLayoutActive()
+        wrapper.getPage() == null
+        wrapper.getContents() == null
+        response.contentAsString == 'plain'
+        response.getHeader('Content-Length') == '5'
+        response.committed
+    }
+
+    void 'not modified status deactivates buffering and routes subsequent output to the target'() {
+        given:
+        def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+        wrapper.setContentType('text/html')
+        wrapper.getWriter().write('discarded')
+
+        when:
+        wrapper.setStatus(304)
+        wrapper.getWriter().write('target')
+        wrapper.getWriter().flush()
+
+        then:
+        response.status == 304
+        !wrapper.isGrailsLayoutActive()
+        response.contentAsString == 'target'
+    }
+
+    void 'error status clears buffered content before writing the error page'() {
+        given:
+        def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+        wrapper.setContentType('text/html')
+        wrapper.getWriter().write('discarded')
+
+        when:
+        wrapper.setStatus(500)
+        wrapper.getWriter().write('<html><body>Error</body></html>')
+
+        then:
+        response.status == 500
+        wrapper.getPage().body == 'Error'
+    }
+
+    void 'redirect and sendError discard buffered data and delegate status to the target'() {
+        given:
+        def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+        wrapper.setContentType('text/html')
+        wrapper.getWriter().write('discarded')
+
+        when:
+        switch (operation) {
+            case 'redirect':
+                wrapper.sendRedirect('/next')
+                break
+            case 'error':
+                wrapper.sendError(404)
+                break
+            case 'message':
+                wrapper.sendError(503, 'Unavailable')
+                break
+        }
+
+        then:
+        response.status == status
+        response.committed
+        response.redirectedUrl == location
+        response.errorMessage == message
+        wrapper.getContents() == null
+
+        where:
+        operation  | status | location | message
+        'redirect' | 302    | '/next'  | null
+        'error'    | 404    | null     | null
+        'message'  | 503    | null     | 'Unavailable'
+    }
+
+    void 'captured GSP page is returned without reparsing and reset when layout is deactivated'() {
+        given:
+        def page = new GSPGrailsLayoutPage()
+        request.setAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE, page)
+        response.setContentType('text/html')
+        def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+        page.addProperty('title', 'Captured')
+
+        expect:
+        wrapper.isGspGrailsLayoutActive()
+        wrapper.getPage().is(page)
+
+        when:
+        wrapper.deactivateGrailsLayout()
+
+        then:
+        !page.isUsed()
+        !wrapper.isGspGrailsLayoutActive()
+        wrapper.getPage() == null
+    }
+
+    void 'include and forward dispatch metadata is delegated through the wrapper'() {
+        given:
+        def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+
+        when:
+        request.getRequestDispatcher('/included').include(request, wrapper)
+        request.getRequestDispatcher('/forwarded').forward(request, wrapper)
+        wrapper.setStatus(201)
+
+        then:
+        response.includedUrls == ['/included']
+        response.forwardedUrl == '/forwarded'
+        wrapper.status == 201
+        response.status == 201
+    }
+}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsViewBufferingResponseSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsViewBufferingResponseSpec.groovy
new file mode 100644
index 0000000000..2029790176
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsViewBufferingResponseSpec.groovy
@@ -0,0 +1,146 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import org.springframework.mock.web.MockHttpServletRequest
+import org.springframework.mock.web.MockHttpServletResponse
+import spock.lang.Specification
+
+class GrailsViewBufferingResponseSpec extends Specification {
+
+    void 'view response buffers HTML written as characters or bytes and builds Content'() {
+        given:
+        def request = new MockHttpServletRequest()
+        def target = new MockHttpServletResponse()
+        target.setContentType('text/html;charset=UTF-8')
+        def response = new GrailsViewBufferingResponse(request, target)
+        def html = '<html><head><title>View</title></head><body>caf\u00e9</body></html>'
+
+        when:
+        if (usingStream) {
+            response.getOutputStream().write(html.getBytes('UTF-8'))
+        } else {
+            response.getWriter().write(html)
+        }
+        def content = response.getContent()
+        def body = new StringWriter()
+        content.writeBody(body)
+
+        then:
+        response.getTargetResponse().is(target)
+        response.isActive()
+        response.isUsingStream() == usingStream
+        content.title == 'View'
+        body.toString() == 'caf\u00e9'
+        target.contentAsByteArray.length == 0
+
+        where:
+        usingStream << [false, true]
+    }
+
+    void 'non HTML responses pass through without producing Content'() {
+        given:
+        def target = new MockHttpServletResponse()
+        def response = new GrailsViewBufferingResponse(new MockHttpServletRequest(), target)
+
+        when:
+        response.setContentType('application/json')
+        response.getWriter().write('{"value":1}')
+        response.getWriter().flush()
+
+        then:
+        !response.isActive()
+        response.getContent() == null
+        target.contentAsString == '{"value":1}'
+    }
+
+    void 'empty HTML writer has no Content to decorate'() {
+        given:
+        def response = new GrailsViewBufferingResponse(new MockHttpServletRequest(), new MockHttpServletResponse())
+        response.setContentType('text/html')
+        response.getWriter().write('')
+
+        expect:
+        response.isActive()
+        response.getContent() == null
+    }
+
+    void 'GSP capture is reused and response mutator deactivation stops buffering'() {
+        given:
+        def request = new MockHttpServletRequest()
+        def page = new GSPGrailsLayoutPage()
+        request.setAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE, page)
+        def target = new MockHttpServletResponse()
+        def response = new GrailsViewBufferingResponse(request, target)
+        response.setContentType('text/html')
+        page.addProperty('title', 'Captured')
+
+        expect:
+        response.getContent().is(page)
+
+        when:
+        response.deactivateResponseMutator()
+        response.getWriter().write('passthrough')
+        response.getWriter().flush()
+
+        then:
+        !response.isActive()
+        !page.isUsed()
+        response.getContent() == null
+        target.contentAsString == 'passthrough'
+    }
+
+    void 'sendError after redirect does not replace the redirect status'() {
+        given:
+        def target = new MockHttpServletResponse()
+        def response = new GrailsViewBufferingResponse(new MockHttpServletRequest(), target)
+
+        when:
+        response.sendRedirect('/next')
+        response.sendError(500)
+        response.sendError(503, 'Unavailable')
+
+        then:
+        target.status == 302
+        target.redirectedUrl == '/next'
+        target.errorMessage == null
+    }
+
+    void 'sendError delegates status and message when the response is uncommitted'() {
+        given:
+        def target = new MockHttpServletResponse()
+        def response = new GrailsViewBufferingResponse(new MockHttpServletRequest(), target)
+
+        when:
+        if (message == null) {
+            response.sendError(404)
+        } else {
+            response.sendError(404, message)
+        }
+
+        then:
+        target.status == 404
+        target.errorMessage == message
+        target.committed
+
+        where:
+        message << [null, 'Missing']
+    }
+}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/LayoutDecoratorSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/LayoutDecoratorSpec.groovy
new file mode 100644
index 0000000000..96b9daec69
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/LayoutDecoratorSpec.groovy
@@ -0,0 +1,121 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import com.opensymphony.module.sitemesh.RequestConstants
+import com.opensymphony.sitemesh.webapp.SiteMeshWebAppContext
+import org.grails.web.util.GrailsApplicationAttributes
+import org.grails.web.util.WebUtils
+import org.springframework.mock.web.MockHttpServletRequest
+import org.springframework.mock.web.MockHttpServletResponse
+import org.springframework.web.servlet.View
+import spock.lang.Specification
+
+class LayoutDecoratorSpec extends Specification {
+
+    void 'no decorator writes the original page unchanged'() {
+        given:
+        def request = new MockHttpServletRequest()
+        def response = new MockHttpServletResponse()
+        def context = new SiteMeshWebAppContext(request, response, request.servletContext)
+        def html = '<html><head><title>Original</title></head><body>Original body</body></html>'
+        def content = new GrailsHTMLPageParser().parseContent(html.toCharArray())
+        def decorator = new GrailsNoDecorator()
+
+        when:
+        decorator.render(content, context)
+
+        then:
+        response.contentAsString == html
+        decorator.getName() == null
+        decorator.getPage() == null
+        decorator.getURIPath() == null
+        decorator.getRole() == null
+        decorator.getInitParameter('missing') == null
+        decorator.getInitParameterNames() == null
+    }
+
+    void 'view decorator makes page and model available during rendering and removes capture attributes afterwards'() {
+        given:
+        def request = new MockHttpServletRequest()
+        def response = new MockHttpServletResponse()
+        def content = new GrailsHTMLPageParser().parseContent('<html><body>Body</body></html>'.toCharArray())
+        def model = [greeting: 'Hello']
+        def view = Mock(View)
+        def decorator = new SpringMVCViewDecorator('main', view)
+
+        when:
+        decorator.render(content, model, request, response, request.servletContext)
+
+        then:
+        1 * view.render(model, request, response) >> { Map values, servletRequest, servletResponse ->
+            assert servletRequest.getAttribute(RequestConstants.PAGE).body == 'Body'
+            assert servletRequest.getAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE) instanceof GSPGrailsLayoutPage
+            servletResponse.getWriter().write(values.greeting + ': Body')
+        }
+        response.contentAsString == 'Hello: Body'
+        response.committed
+        request.getAttribute(RequestConstants.PAGE) == null
+        request.getAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE) == null
+        decorator.getView().is(view)
+        decorator.getName() == 'main'
+        decorator.getTemplate() == null
+    }
+
+    void 'committed response skips layout rendering'() {
+        given:
+        def request = new MockHttpServletRequest()
+        def response = new MockHttpServletResponse()
+        response.setCommitted(true)
+        def view = Mock(View)
+        def decorator = new SpringMVCViewDecorator('main', view)
+        def context = new SiteMeshWebAppContext(request, response, request.servletContext)
+
+        when:
+        decorator.render(new GSPGrailsLayoutPage(), context)
+
+        then:
+        0 * view.render(_, _, _)
+        request.getAttribute(RequestConstants.PAGE) == null
+        request.getAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE) == null
+    }
+
+    void 'render failure is wrapped with layout name and clears page scope and layout selection'() {
+        given:
+        def request = new MockHttpServletRequest()
+        def response = new MockHttpServletResponse()
+        request.setAttribute(GrailsApplicationAttributes.PAGE_SCOPE, 'scope')
+        request.setAttribute(WebUtils.LAYOUT_ATTRIBUTE, 'main')
+        def cause = new IllegalStateException('Cannot render')
+        def view = Mock(View)
+        def decorator = new SpringMVCViewDecorator('main', view)
+
+        when:
+        decorator.render(new GSPGrailsLayoutPage(), [:], request, response, request.servletContext)
+
+        then:
+        1 * view.render(_, request, response) >> { throw cause }
+        def error = thrown(RuntimeException)
+        error.message == 'Error applying layout : main'
+        error.cause.is(cause)
+        request.getAttribute(GrailsApplicationAttributes.PAGE_SCOPE) == null
+        request.getAttribute(WebUtils.LAYOUT_ATTRIBUTE) == null
+    }
+}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderGrailsLayoutTagLibSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderGrailsLayoutTagLibSpec.groovy
new file mode 100644
index 0000000000..887f949bdc
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderGrailsLayoutTagLibSpec.groovy
@@ -0,0 +1,92 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import com.opensymphony.module.sitemesh.RequestConstants
+import grails.testing.web.taglib.TagLibUnitTest
+import org.grails.buffer.FastStringWriter
+import org.grails.plugins.web.taglib.RenderGrailsLayoutTagLib
+import spock.lang.Specification
+
+class RenderGrailsLayoutTagLibSpec extends Specification implements TagLibUnitTest<RenderGrailsLayoutTagLib> {
+
+    GSPGrailsLayoutPage page
+
+    void setup() {
+        page = new GSPGrailsLayoutPage()
+        request.setAttribute(RequestConstants.PAGE, page)
+    }
+
+    void 'pageProperty renders values default values and entire attributes'() {
+        given:
+        page.addProperty('body.class', 'content')
+
+        expect:
+        applyTemplate('<g:pageProperty name="body.class"/>') == 'content'
+        applyTemplate('<g:pageProperty name="body.class" writeEntireProperty="true"/>') == ' class="content"'
+        applyTemplate('<g:pageProperty name="missing" default="fallback"/>') == 'fallback'
+        applyTemplate('<g:pageProperty name="missing"/>') == ''
+    }
+
+    void 'captured component buffers can be rendered and tested as page properties'() {
+        given:
+        def writer = new FastStringWriter()
+        writer.write('Sidebar')
+        page.setContentBuffer('sidebar', writer.getBuffer())
+
+        expect:
+        applyTemplate('<g:pageProperty name="page.sidebar"/>') == 'Sidebar'
+        applyTemplate('<g:ifPageProperty name="page.sidebar">Present</g:ifPageProperty>') == 'Present'
+    }
+
+    void 'ifPageProperty requires existing properties and matching values'() {
+        given:
+        page.addProperty('page.theme', 'dark')
+        page.addProperty('page.mode', 'wide')
+
+        expect:
+        applyTemplate('<g:ifPageProperty name="page.theme" equals="dark">Yes</g:ifPageProperty>') == 'Yes'
+        applyTemplate('<g:ifPageProperty name="page.theme" equals="light">No</g:ifPageProperty>') == ''
+        applyTemplate('<g:ifPageProperty name="missing">No</g:ifPageProperty>') == ''
+        applyTemplate('<g:ifPageProperty>No</g:ifPageProperty>') == ''
+        applyTemplate('<g:ifPageProperty name="${[\'page.theme\', \'page.mode\']}" ' +
+                'equals="${[\'dark\', \'wide\']}">Both</g:ifPageProperty>') == 'Both'
+    }
+
+    void 'layout tags render captured title head and body with a default title when absent'() {
+        given:
+        def head = new FastStringWriter()
+        head.write('<meta name="layout" content="main">')
+        def body = new FastStringWriter()
+        body.write('Body')
+        page.setHeadBuffer(head.getBuffer())
+        page.setBodyBuffer(body.getBuffer())
+
+        expect:
+        applyTemplate('<g:layoutTitle default="Default"/>|<g:layoutHead/>|<g:layoutBody/>') ==
+                'Default|<meta name="layout" content="main">|Body'
+
+        when:
+        page.addProperty('title', 'Title')
+
+        then:
+        applyTemplate('<g:layoutTitle default="Default"/>') == 'Title'
+    }
+}