Index: django/test/__init__.py
===================================================================
Index: django/test/browser.py
===================================================================
--- django/test/browser.py	(revision 0)
+++ django/test/browser.py	(revision 0)
@@ -0,0 +1,209 @@
+from cStringIO import StringIO
+from django.contrib.admin.views.decorators import LOGIN_FORM_KEY, _encode_post_data
+from django.core.handlers.base import BaseHandler
+from django.core.handlers.wsgi import WSGIRequest
+from django.dispatch import dispatcher
+from django.http import urlencode, SimpleCookie
+from django.template import signals
+from django.utils.functional import curry
+
+class BrowserHandler(BaseHandler):
+    """
+    A HTTP Handler that can be used for testing purposes. 
+    Uses the WSGI interface to compose requests, but returns
+    the raw HttpResponse object
+    """
+    def __call__(self, environ):
+        from django.conf import settings
+        from django.core import signals
+
+        # Set up middleware if needed. We couldn't do this earlier, because
+        # settings weren't available.
+        if self._request_middleware is None:
+            self.load_middleware()
+
+        dispatcher.send(signal=signals.request_started)
+        try:
+            request = WSGIRequest(environ)
+            response = self.get_response(request.path, request)
+
+            # Apply response middleware
+            for middleware_method in self._response_middleware:
+                response = middleware_method(request, response)
+
+        finally:
+            dispatcher.send(signal=signals.request_finished)
+        
+        return response
+
+def store_rendered_templates(store, signal, sender, template, context):
+    "A utility function for storing templates and contexts that are rendered"
+    store.setdefault('template',[]).append(template)
+    store.setdefault('context',[]).append(context)
+
+def encode_multipart(boundary, data):
+    """
+    A simple method for encoding multipart POST data from a dictionary of
+    form values.
+    
+    The key will be used as the form data name; the value will be transmitted
+    as content. If the value is a file, the contents of the file will be sent
+    as an application/octet-stream; otherwise, str(value) will be sent.
+    """
+    lines = []
+    for (key, value) in data.items():
+        if isinstance(value, file):
+            lines.append('--' + boundary)
+            lines.append('Content-Disposition: form-data; name="%s"' % key)
+            lines.append('')
+            lines.append('--' + boundary)
+            lines.append('Content-Disposition: form-data; name="%s_file"; filename="%s"' % (key, value.name))
+            lines.append('Content-Type: application/octet-stream')
+            lines.append('')
+            lines.append(value.read())
+        else:
+            lines.append('--' + boundary)
+            lines.append('Content-Disposition: form-data; name="%s"' % key)
+            lines.append('')
+            lines.append(str(value))
+        
+    lines.append('--' + boundary + '--')
+    lines.append('')
+    return '\r\n'.join(lines)
+
+class Browser:
+    """
+    A class that can act as a browser for testing purposes. 
+      
+    It allows the user to compose GET and POST requests, and
+    obtain the response that the server gave to those requests.
+    The server Response objects are annotated with the details
+    of the contexts and templates that were rendered during the
+    process of serving the request.
+
+    Browser objects are stateful - they will retain cookie (and
+    thus session) details for the lifetime of the Browser instance.
+    
+    This is not intended as a replacement for Twill/Selenium or
+    the like - it is here to allow testing against the
+    contexts and templates produced by a view, rather than the
+    HTML rendered to the end-user.
+    """
+    def __init__(self, **defaults):
+        self.handler = TestHandler()
+        self.defaults = defaults
+        self.cookie = SimpleCookie()
+        
+    def request(self, **request):
+        """
+        The master request method. Composes the environment dictionary 
+        and passes to the handler, returning the result of the handler.
+        Assumes defaults for the query environment, which can be overridden
+        using the arguments to the request.
+        """
+
+
+        # Set up the base request environment
+        environ = {
+            'HTTP_COOKIE':      self.cookie,
+            'PATH_INFO':         '/',
+            'QUERY_STRING':      '',
+            'REQUEST_METHOD':    'GET',
+            'SCRIPT_NAME':       None, 
+            'SERVER_NAME':       'testserver',
+            'SERVER_PORT':       80,
+            'SERVER_PROTOCOL':   'HTTP/1.1',
+        }
+        
+        # Appy any default or request specific environment updates 
+        environ.update(self.defaults)
+        environ.update(request)        
+
+        # Curry a data dictionary into an instance of
+        # the template renderer callback function
+        data = {}
+        on_template_render = curry(store_rendered_templates, data)
+        
+        # Connect the curried function to the template_rendered signal
+        dispatcher.connect(on_template_render, signal=signals.template_rendered)
+
+        # Handle the request
+        response = self.handler(environ)
+        
+        # Add any rendered template detail to the response
+        # If there was only one template rendered (the most likely case), 
+        # flatten the list to a single element
+        for detail in ('template', 'context'):
+            if data.get(detail):
+                if len(data[detail]) == 1:
+                    setattr(response, detail, data[detail][0]);
+                else:
+                    setattr(response, detail, data[detail])
+            else:
+                setattr(response, detail, None)
+        
+        # If the response requested a new cookie be set, store it
+        if response.cookies:
+            self.cookie.update(response.cookies)
+        return response
+        
+    def get(self, path, data={}, **extra):
+        "Request a response from the server using GET."
+        r = {
+            'CONTENT_LENGTH':  None,
+            'CONTENT_TYPE':    'text/html; charset=utf-8',
+            'PATH_INFO':       path,
+            'QUERY_STRING':    urlencode(data),
+            'REQUEST_METHOD': 'GET',
+        }
+        r.update(extra)
+        
+        return self.request(**r)
+    
+    def post(self, path, data={}, **extra):
+        "Request a response from the server using POST."
+        
+        BOUNDARY = 'BoUnDaRyStRiNg'
+
+        encoded = encode_multipart(BOUNDARY, data)
+        stream = StringIO(encoded)
+        r = {
+            'CONTENT_LENGTH': len(encoded),
+            'CONTENT_TYPE':   'multipart/form-data; boundary=%s' % BOUNDARY,
+            'PATH_INFO':      path,
+            'REQUEST_METHOD': 'POST',
+            'wsgi.input':     stream,
+        }
+        r.update(extra)
+        
+        return self.request(**r)
+
+    def login(self, path, username, password, **extra):
+        """
+        A specialized sequence of GET and POST to log into a view that
+        is protected by @login_required or a similar access decorator.
+        
+        path should be the URL of the login page, or of any page that
+        is login protected.
+        
+        Returns True if login was successful; False if otherwise.        
+        """
+        # First, GET the login page. 
+        # This is required to establish the session.
+        response = self.get(path)
+        if response.status_code != 200:
+            return False
+
+        # Set up the block of form data required by the login page.
+        form_data = {
+            'username': username,
+            'password': password,
+            'this_is_the_login_form':1,
+            'post_data':_encode_post_data({LOGIN_FORM_KEY: 1})
+        }
+        response = self.post('/admin/', data=form_data, **extra)
+        
+        # login page should response 200 (if you requested the login
+        # page specifically), or 302 (if you requested a login
+        # protected page, to which the login can redirect).
+        return response.status_code in (200,302)
Index: django/views/debug.py
===================================================================
--- django/views/debug.py	(revision 3355)
+++ django/views/debug.py	(working copy)
@@ -117,7 +117,7 @@
             'function': '?',
             'lineno': '?',
         }]
-    t = Template(TECHNICAL_500_TEMPLATE)
+    t = Template(TECHNICAL_500_TEMPLATE, name='Technical 500 Template')
     c = Context({
         'exception_type': exc_type.__name__,
         'exception_value': exc_value,
@@ -143,7 +143,7 @@
             # tried exists but is an empty list. The URLconf must've been empty.
             return empty_urlconf(request)
 
-    t = Template(TECHNICAL_404_TEMPLATE)
+    t = Template(TECHNICAL_404_TEMPLATE, name='Technical 404 Template')
     c = Context({
         'root_urlconf': settings.ROOT_URLCONF,
         'urlpatterns': tried,
@@ -156,7 +156,7 @@
 
 def empty_urlconf(request):
     "Create an empty URLconf 404 error response."
-    t = Template(EMPTY_URLCONF_TEMPLATE)
+    t = Template(EMPTY_URLCONF_TEMPLATE, name='Empty URLConf Template')
     c = Context({
         'project_name': settings.SETTINGS_MODULE.split('.')[0]
     })
Index: django/views/static.py
===================================================================
--- django/views/static.py	(revision 3355)
+++ django/views/static.py	(working copy)
@@ -82,7 +82,7 @@
     try:
         t = loader.get_template('static/directory_index')
     except TemplateDoesNotExist:
-        t = Template(DEFAULT_DIRECTORY_INDEX_TEMPLATE)
+        t = Template(DEFAULT_DIRECTORY_INDEX_TEMPLATE, name='Default Directory Index Template')
     files = []
     for f in os.listdir(fullpath):
         if not f.startswith('.'):
Index: django/template/__init__.py
===================================================================
--- django/template/__init__.py	(revision 3355)
+++ django/template/__init__.py	(working copy)
@@ -60,6 +60,8 @@
 from django.template.context import Context, RequestContext, ContextPopException
 from django.utils.functional import curry
 from django.utils.text import smart_split
+from django.dispatch import dispatcher
+from django.template import signals
 
 __all__ = ('Template', 'Context', 'RequestContext', 'compile_string')
 
@@ -137,13 +139,14 @@
         return self.source
 
 class Template(object):
-    def __init__(self, template_string, origin=None):
+    def __init__(self, template_string, origin=None, name='<Unknown Template>'):
         "Compilation stage"
         if settings.TEMPLATE_DEBUG and origin == None:
             origin = StringOrigin(template_string)
             # Could do some crazy stack-frame stuff to record where this string
             # came from...
         self.nodelist = compile_string(template_string, origin)
+        self.name = name
 
     def __iter__(self):
         for node in self.nodelist:
@@ -152,6 +155,7 @@
 
     def render(self, context):
         "Display stage -- can be called many times"
+        dispatcher.send(signal=signals.template_rendered, sender=self, template=self, context=context)
         return self.nodelist.render(context)
 
 def compile_string(template_string, origin):
Index: django/template/signals.py
===================================================================
--- django/template/signals.py	(revision 0)
+++ django/template/signals.py	(revision 0)
@@ -0,0 +1 @@
+template_rendered=object()
\ No newline at end of file
Index: django/template/defaulttags.py
===================================================================
--- django/template/defaulttags.py	(revision 3355)
+++ django/template/defaulttags.py	(working copy)
@@ -251,7 +251,7 @@
             output = ''
         if self.parsed:
             try:
-                t = Template(output)
+                t = Template(output, name=self.filepath)
                 return t.render(context)
             except TemplateSyntaxError, e:
                 if settings.DEBUG:
Index: django/template/loader_tags.py
===================================================================
--- django/template/loader_tags.py	(revision 3355)
+++ django/template/loader_tags.py	(working copy)
@@ -55,7 +55,7 @@
         except TemplateDoesNotExist:
             raise TemplateSyntaxError, "Template %r cannot be extended, because it doesn't exist" % parent
         else:
-            return get_template_from_string(source, origin)
+            return get_template_from_string(source, origin, parent)
 
     def render(self, context):
         compiled_parent = self.get_parent(context)
Index: django/template/loader.py
===================================================================
--- django/template/loader.py	(revision 3355)
+++ django/template/loader.py	(working copy)
@@ -76,14 +76,16 @@
     Returns a compiled Template object for the given template name,
     handling template inheritance recursively.
     """
-    return get_template_from_string(*find_template_source(template_name))
+    source, origin = find_template_source(template_name)
+    template = get_template_from_string(source, origin, template_name)
+    return template
 
-def get_template_from_string(source, origin=None):
+def get_template_from_string(source, origin=None, name=None):
     """
     Returns a compiled Template object for the given template code,
     handling template inheritance recursively.
     """
-    return Template(source, origin)
+    return Template(source, origin, name)
 
 def render_to_string(template_name, dictionary=None, context_instance=None):
     """
