diff --git a/GPipe-GLFW.cabal b/GPipe-GLFW.cabal
--- a/GPipe-GLFW.cabal
+++ b/GPipe-GLFW.cabal
@@ -1,5 +1,5 @@
 name:                GPipe-GLFW
-version:             1.2.2
+version:             1.2.3
 cabal-version:       >=1.10
 build-type:          Simple
 author:              Patrick Redmond
@@ -19,28 +19,41 @@
 
 library
   hs-source-dirs:      src
-  build-depends:
-                       base >=4.7 && <4.9,
-                       transformers >= 0.3 && < 0.5,
-                       GLFW-b >=1.4 && <1.5,
-                       GPipe >=2.1 && <2.2
+  build-depends:       base         >= 4.7 && <4.10
+                     , transformers >= 0.3 && <0.6
+                     , GLFW-b       >= 1.4 && <1.5
+                     , GPipe        >= 2.1 && <2.2
   ghc-options:         -Wall
   default-language:    Haskell2010
   exposed-modules:     Graphics.GPipe.Context.GLFW
-                     , Graphics.GPipe.Context.GLFW.Input
-                     , Graphics.GPipe.Context.GLFW.Unsafe
-  other-modules:
-                       Graphics.GPipe.Context.GLFW.Resource
+                       Graphics.GPipe.Context.GLFW.Input
+                       Graphics.GPipe.Context.GLFW.Unsafe
+  other-modules:       Graphics.GPipe.Context.GLFW.Resource
                        Graphics.GPipe.Context.GLFW.Util
                        Graphics.GPipe.Context.GLFW.Format
 
+executable gpipe-glfw-smoketests
+  hs-source-dirs:      test
+  main-is:             Spec.hs
+  build-depends:       base
+                     , GPipe
+                     , GPipe-GLFW
+                     , transformers
+                     , exception-transformers
+  ghc-options:         -threaded -rtsopts -with-rtsopts=-N
+  default-language:    Haskell2010
+  other-modules:       Test.Common
+                       Test.Basic
+                       Test.Split
+                       Test.Multi
+
 source-repository head
   type:     git
-  location: git@github.com:plredmond/GPipe-GLFW.git
+  location: https://github.com/plredmond/GPipe-GLFW.git
   subdir:   GPipe-GLFW
 
 source-repository this
   type:     git
-  location: git@github.com:plredmond/GPipe-GLFW.git
+  location: https://github.com/plredmond/GPipe-GLFW.git
   subdir:   GPipe-GLFW
-  tag:      v1.2.2
+  tag:      v1.2.3
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,12 @@
+import qualified System.Environment as Env
+import qualified Test.Basic as Basic
+import qualified Test.Split as Split
+import qualified Test.Multi as Multi
+
+main :: IO ()
+main = do
+    putStrLn "\n= Running smoke tests"
+    Env.lookupEnv "LIBGL_ALWAYS_SOFTWARE" >>= \val -> putStrLn $ "LIBGL_ALWAYS_SOFTWARE: " ++ show val
+    Basic.test (3 * 60)
+    Split.test (3 * 60)
+    Multi.test (3 * 60)
diff --git a/test/Test/Basic.hs b/test/Test/Basic.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Basic.hs
@@ -0,0 +1,17 @@
+module Test.Basic where
+
+import Graphics.GPipe
+import qualified Graphics.GPipe.Context.GLFW as GLFW
+import qualified Test.Common as C
+
+-- Basic Test
+
+test :: Int -> IO ()
+test frameCount = do
+    putStrLn "== Basic Test"
+    putStrLn "\tRender a scene to a window."
+    runContextT GLFW.newContext (ContextFormatColorDepth RGB8 Depth16) $ do
+        resources <- C.initRenderContext [C.xAxis, C.yAxis, C.zAxis, C.plane]
+        C.mainloop (frameCount, 0) resources
+
+-- eof
diff --git a/test/Test/Common.hs b/test/Test/Common.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Common.hs
@@ -0,0 +1,128 @@
+{-# LANGUAGE Rank2Types #-}
+module Test.Common where
+
+import Control.Monad.Exception (MonadException)
+import Control.Monad.IO.Class (MonadIO)
+import Data.Traversable (forM)
+
+import Graphics.GPipe
+
+xAxis :: [(V3 Float, V3 Float)]
+xAxis = zip (repeat $ V3 1 0 0) -- red
+    [V3 (-1) 0 0, V3 1 0 0, V3 0.8 (-0.1) 0, V3 0.8 0 0, V3 1 (-0.1) 0]
+
+yAxis :: [(V3 Float, V3 Float)]
+yAxis = zip (repeat $ V3 0 1 0) -- green
+    [V3 0 (-1) 0, V3 0 1 0, V3 0 0.8 (-0.1), V3 0 0.9 (-0.05), V3 0 1 (-0.1)]
+
+zAxis :: [(V3 Float, V3 Float)]
+zAxis = zip (repeat $ V3 0 0 1) -- blue
+    [V3 0 0 (-1), V3 0 0 1, V3 (-0.1) 0 0.8, V3 (-0.1) 0 1]
+
+plane :: [(V3 Float, V3 Float)]
+plane = zip (repeat $ V3 1 1 1) -- white
+    [V3 (-2) (-0.1) (-2), V3 (-2) (-0.1) 2, V3 2 (-0.1) 2, V3 2 (-0.1) (-2), V3 (-2) (-0.1) (-2)]
+
+-- TODO: make tests
+--   * basic
+--   * split-thread load/render
+--   * render multiple windows
+
+data ShaderEnv os = ShaderEnv
+    { extractProjU :: (Buffer os (Uniform (V4 (B4 Float))), Int)
+    , extractLinePA :: PrimitiveArray Lines (B3 Float, B3 Float)
+    , extractRastOpts :: (Side, ViewPort, DepthRange)
+    }
+
+initBuffers
+    :: MonadIO m
+    => [[(V3 Float, V3 Float)]]
+    -> ContextT w os f m ([Buffer os (B3 Float, B3 Float)], Buffer os (Uniform (V4 (B4 Float))))
+initBuffers rawMeshes = do
+    -- make mesh buffers
+    meshesB <- forM rawMeshes $ \pts -> do
+        buf <- newBuffer $ length pts
+        writeBuffer buf 0 pts
+        return buf
+    -- make projection matrix buffer
+    projMatB <- newBuffer 1
+    return (meshesB, projMatB)
+
+projectLines :: forall os f. Shader os f (ShaderEnv os) (FragmentStream (V3 FFloat, FFloat))
+projectLines = do
+    projMat <- getUniform extractProjU
+    linePS <- toPrimitiveStream extractLinePA
+    -- project points
+    let projectedLinePS = (\(c, p) -> (projMat !* point p, c)) <$> linePS
+    lineFS <- rasterize extractRastOpts projectedLinePS
+    -- write fragment depths and return frags
+    return $ withRasterizedInfo (\fr inf -> (fr, depth inf)) lineFS
+    where
+        depth RasterizedInfo {rasterizedFragCoord = (V4 _ _ z _)} = z
+
+-- TODO: just adds the draw call to the end of the shader & glues some init functions together
+initRenderContext
+    :: (MonadIO m, MonadException m)
+    => [[(V3 Float, V3 Float)]]
+    -> ContextT w os (ContextFormat RGBFloat Depth) m
+        ( [Buffer os (B3 Float, B3 Float)]
+        , Buffer os (Uniform (V4 (B4 Float)))
+        , CompiledShader os (ContextFormat RGBFloat Depth) (ShaderEnv os)
+        )
+initRenderContext rawMeshes = do
+    projShader <- compileShader (projectLines >>= drawContextColorDepth (const (ContextColorOption NoBlending $ pure True, DepthOption Less True)))
+    (meshesB, projMatB) <- initBuffers rawMeshes
+    return (meshesB, projMatB, projShader)
+
+computeProjMat :: Float -> V2 Int -> M44 Float
+computeProjMat frac (V2 w h) = camera2clip !*! world2camera !*! model2world
+    where
+        t = frac * 2 * pi
+        model2world = identity
+        world2camera = lookAt
+            -- eye: camera position in world coordinates
+            (V3 (3 * sin t) (0.5 + cos (t * 2)) (3 * cos t))
+            (V3 0 0.1 0) -- center: camera look-at target position in world coords
+            (V3 0 1 0) -- up: camera up-vector
+        camera2clip = perspective
+            (pi / 3) -- 60deg field of view "in y direction"
+            (fromIntegral w / fromIntegral h) -- aspect ratio
+            1 100 -- near and far clipping plane
+
+basicRenderer
+    :: V2 Int
+    -> ( [Buffer os (B3 Float, B3 Float)]
+       , Buffer os (Uniform (V4 (B4 Float)))
+       , CompiledShader os (ContextFormat RGBFloat Depth) (ShaderEnv os)
+       )
+    -> Render os (ContextFormat RGBFloat Depth) ()
+basicRenderer size (meshesB, projMatB, projShader) = do
+    clearContextColor 0.2 -- grey
+    clearContextDepth 1 -- far plane
+    meshPAs <- forM meshesB $ \mesh -> do
+        meshVA <- newVertexArray mesh
+        return $ toPrimitiveArray LineStrip meshVA
+    projShader $ ShaderEnv (projMatB, 0) (mconcat meshPAs) (Front, ViewPort 0 size, DepthRange 0 1)
+
+mainloop
+    ::  (MonadIO m, MonadException m)
+    => (Int, Int)
+    -> ( [Buffer os (B3 Float, B3 Float)]
+       , Buffer os (Uniform (V4 (B4 Float)))
+       , CompiledShader os (ContextFormat RGBFloat Depth) (ShaderEnv os)
+       )
+    -> ContextT w os (ContextFormat RGBFloat Depth) m ()
+mainloop frame resources@(_, projMatB, _)
+    | done frame = return ()
+    | otherwise = do
+        -- compute a projection matrix & write it to the buffer
+        size <- getContextBuffersSize
+        writeBuffer projMatB 0 [computeProjMat (frac frame) size]
+        -- render the scene and then loop
+        render $ basicRenderer size resources
+        swapContextBuffers
+        mainloop (next frame) resources
+    where
+        done (maxFrame, curFrame) = curFrame > maxFrame
+        frac (maxFrame, curFrame) = fromIntegral curFrame / fromIntegral maxFrame
+        next = fmap (+1)
diff --git a/test/Test/Multi.hs b/test/Test/Multi.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Multi.hs
@@ -0,0 +1,16 @@
+module Test.Multi where
+
+import Graphics.GPipe
+import qualified Graphics.GPipe.Context.GLFW as GLFW
+import qualified Test.Common as C
+
+test :: Int -> IO ()
+test _ = do
+    putStrLn "== Multi window test"
+    putStrLn "\tUse shared contexts to load resources and render different subsets to different windows."
+    runContextT GLFW.newContext (ContextFormatColorDepth RGB8 Depth16) $ do
+        -- TODO: in main thread render the axes
+
+        -- TODO: fork; in other thread render just C.plane
+        runSharedContextT (ContextFormatColorDepth RGB32F Depth16) $ do
+            return ()
diff --git a/test/Test/Split.hs b/test/Test/Split.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Split.hs
@@ -0,0 +1,18 @@
+module Test.Split where
+
+import Graphics.GPipe
+import qualified Graphics.GPipe.Context.GLFW as GLFW
+--import qualified Test.Common as C
+
+test :: Int -> IO ()
+test _ = do
+    putStrLn "== Split thread test"
+    putStrLn "\tUse shared contexts to load resources on one thread and render on another."
+    runContextT GLFW.newContext (ContextFormatColorDepth RGB8 Depth16) $ do
+        -- TODO: in main thread, make buffers but leave them empty
+
+        -- TODO: fork; in other thread change contents of buffers once a second
+        runSharedContextT (ContextFormatNone) $ do
+            return ()
+
+-- eof
